From f5fb95d488b35888f7c40e35e1e7b01a4d3e4e03 Mon Sep 17 00:00:00 2001
From: Christian Kuhn <lolli@schwarzbu.ch>
Date: Tue, 17 Oct 2023 13:27:58 +0200
Subject: [PATCH] [TASK] Streamline ReferenceIndex->updateRefIndexTable()
MIME-Version: 1.0
Content-Type: text/plain; charset=UTF-8
Content-Transfer-Encoding: 8bit

With ReferenceIndex->updateIndex() being pretty much
cleaned up and optimized, we start looking at the main
worker method updateRefIndexTable().

The patch refactors some details to make the method
more easy to follow. updateIndex() now hands over the
current record to suppress another DB call per row which
improves 'bin/typo3 referenceindex:update' performance.

Resolves: #102189
Releases: main
Change-Id: I2bbd571162deb6bff676f46f6dc7d836b823eccf
Reviewed-on: https://review.typo3.org/c/Packages/TYPO3.CMS/+/81443
Reviewed-by: Christian Kuhn <lolli@schwarzbu.ch>
Reviewed-by: Stefan Bürk <stefan@buerk.tech>
Tested-by: Stefan Bürk <stefan@buerk.tech>
Tested-by: Christian Kuhn <lolli@schwarzbu.ch>
Reviewed-by: Benni Mack <benni@typo3.org>
Tested-by: core-ci <typo3@b13.com>
Tested-by: Benni Mack <benni@typo3.org>
---
 .../core/Classes/Database/ReferenceIndex.php  | 322 +++++++-----------
 ...pages-and-ttcontent-with-corrupt-image.xml |  54 +--
 ...-ttcontent-with-image-but-not-included.xml |  54 +--
 .../pages-and-ttcontent-with-image.xml        |  54 +--
 .../pages-and-ttcontent-with-softrefs.xml     |  82 ++---
 5 files changed, 250 insertions(+), 316 deletions(-)

diff --git a/typo3/sysext/core/Classes/Database/ReferenceIndex.php b/typo3/sysext/core/Classes/Database/ReferenceIndex.php
index e15790807b3b..bfe37e95ed34 100644
--- a/typo3/sysext/core/Classes/Database/ReferenceIndex.php
+++ b/typo3/sysext/core/Classes/Database/ReferenceIndex.php
@@ -79,8 +79,7 @@ class ReferenceIndex
 
     /**
      * A list of fields that may contain relations per TCA table.
-     * This is either ['*'] or an array of single field names. The list
-     * depends on TCA and is built when a first table row is handled.
+     * The list depends on TCA, entries are created once per table, per class instance.
      */
     protected array $tableRelationFieldCache = [];
 
@@ -93,102 +92,81 @@ class ReferenceIndex
     }
 
     /**
-     * Call this function to update the sys_refindex table for a record (even one just deleted)
-     * NOTICE: Currently, references updated for a deleted-flagged record will not include those from within FlexForm
-     * fields in some cases where the data structure is defined by another record since the resolving process ignores
-     * deleted records! This will also result in bad cleaning up in DataHandler I think... Anyway, that's the story of
-     * FlexForms; as long as the DS can change, lots of references can get lost in no time.
+     * Update the sys_refindex table for a record, even one just deleted.
+     * This is used by DataHandler ReferenceIndexUpdater as entry method to take care of single records.
+     * It is also used internally via updateIndex() by CLI "referenceindex:update" and lowlevel BE module.
      *
+     * @param array|null $currentRecord Current full (select *) record from DB. Optimization for updateIndex().
      * @return array Statistics about how many index records were added, deleted and not altered.
      */
-    public function updateRefIndexTable(string $tableName, int $uid, bool $testOnly = false, int $workspaceUid = 0): array
+    public function updateRefIndexTable(string $tableName, int $uid, bool $testOnly = false, int $workspaceUid = 0, array $currentRecord = null): array
     {
         $this->workspaceId = $workspaceUid;
-
         $result = [
             'keptNodes' => 0,
             'deletedNodes' => 0,
             'addedNodes' => 0,
         ];
-
-        // Not a valid uid, the table is excluded, or can not contain relations.
-        if ($uid < 1 || $this->shouldExcludeTableFromReferenceIndex($tableName) || !$this->hasTableRelationFields($tableName)) {
+        if ($uid < 1 || $this->shouldExcludeTableFromReferenceIndex($tableName) || empty($this->getTableRelationFields($tableName))) {
+            // Not a valid uid, the table is excluded, or can not contain relations.
             return $result;
         }
-
-        $connection = $this->connectionPool->getConnectionForTable('sys_refindex');
-
-        // Get current index from Database with hash as index. sys_refindex is not a TCA table, so no restrictions.
-        $queryBuilder = $connection->createQueryBuilder();
-        $queryBuilder->getRestrictions()->removeAll();
-        $queryResult = $queryBuilder->select('hash')->from('sys_refindex')->where(
-            $queryBuilder->expr()->eq('tablename', $queryBuilder->createNamedParameter($tableName)),
-            $queryBuilder->expr()->eq('recuid', $queryBuilder->createNamedParameter($uid, Connection::PARAM_INT)),
-            $queryBuilder->expr()->eq('workspace', $queryBuilder->createNamedParameter($this->workspaceId, Connection::PARAM_INT))
-        )->executeQuery();
-        $currentRelationHashes = [];
-        while ($relation = $queryResult->fetchAssociative()) {
-            $currentRelationHashes[$relation['hash']] = true;
+        if ($currentRecord === null) {
+            // Fetch record if not provided.
+            $currentRecord = BackendUtility::getRecord($tableName, $uid);
+        }
+        if ($currentRecord === null) {
+            // If there is no record because it was hard or soft-deleted, remove any existing sys_refindex rows of it.
+            $currentRelationHashes = $this->getCurrentRelationHashes($tableName, $uid, $workspaceUid);
+            $numberOfLeftOverRelationHashes = count($currentRelationHashes);
+            $result['deletedNodes'] = $numberOfLeftOverRelationHashes;
+            if ($numberOfLeftOverRelationHashes > 0 && !$testOnly) {
+                $this->removeRelationHashes($currentRelationHashes);
+            }
+            return $result;
         }
 
-        // Handle this record.
-        $existingRecord = $this->getRecord($tableName, $uid);
-        if ($existingRecord) {
-            // Table has relation fields and record exists - get relations
-            $this->relations = [];
-            $relations = $this->generateDataUsingRecord($tableName, $existingRecord);
-            // Traverse the generated index:
-            foreach ($relations as &$relation) {
-                if (!is_array($relation)) {
-                    continue;
-                }
-                // Exclude any relations TO a specific table
-                if (($relation['ref_table'] ?? '') && $this->shouldExcludeTableFromReferenceIndex($relation['ref_table'])) {
-                    continue;
-                }
-                $relation['hash'] = md5(implode('///', $relation) . '///' . $this->hashVersion);
-                // First, check if already indexed and if so, unset that row (so in the end we know which rows to remove!)
-                if (isset($currentRelationHashes[$relation['hash']])) {
-                    unset($currentRelationHashes[$relation['hash']]);
-                    $result['keptNodes']++;
-                    $relation['_ACTION'] = 'KEPT';
-                } else {
-                    // If new, add it:
-                    if (!$testOnly) {
-                        $connection->insert('sys_refindex', $relation);
-                    }
-                    $result['addedNodes']++;
-                    $relation['_ACTION'] = 'ADDED';
+        $currentRelationHashes = $this->getCurrentRelationHashes($tableName, $uid, $workspaceUid);
+        $this->relations = [];
+        $relations = $this->generateDataUsingRecord($tableName, $currentRecord);
+        $connection = $this->connectionPool->getConnectionForTable('sys_refindex');
+        foreach ($relations as &$relation) {
+            if (!is_array($relation)) {
+                continue;
+            }
+            // Exclude any relations TO a specific table
+            if (($relation['ref_table'] ?? '') && $this->shouldExcludeTableFromReferenceIndex($relation['ref_table'])) {
+                continue;
+            }
+            $relation['hash'] = md5(implode('///', $relation) . '///' . $this->hashVersion);
+            // First, check if already indexed and if so, unset that row (so in the end we know which rows to remove!)
+            if (isset($currentRelationHashes[$relation['hash']])) {
+                unset($currentRelationHashes[$relation['hash']]);
+                $result['keptNodes']++;
+                $relation['_ACTION'] = 'KEPT';
+            } else {
+                // If new, add it:
+                if (!$testOnly) {
+                    $connection->insert('sys_refindex', $relation);
                 }
+                $result['addedNodes']++;
+                $relation['_ACTION'] = 'ADDED';
             }
-            $result['relations'] = $relations;
         }
+        $result['relations'] = $relations;
 
-        // If any existing are left, they are not in the current set anymore, and removed
+        // If any existing are left, they are not in the current set anymore. Remove them.
         $numberOfLeftOverRelationHashes = count($currentRelationHashes);
         $result['deletedNodes'] = $numberOfLeftOverRelationHashes;
         if ($numberOfLeftOverRelationHashes > 0 && !$testOnly) {
-            $maxBindParameters = PlatformInformation::getMaxBindParameters($connection->getDatabasePlatform());
-            $chunks = array_chunk(array_keys($currentRelationHashes), $maxBindParameters - 10, true);
-            foreach ($chunks as $chunk) {
-                if (empty($chunk)) {
-                    continue;
-                }
-                $queryBuilder = $connection->createQueryBuilder();
-                $queryBuilder
-                    ->delete('sys_refindex')
-                    ->where(
-                        $queryBuilder->expr()->in('hash', $queryBuilder->createNamedParameter($chunk, Connection::PARAM_STR_ARRAY))
-                    )
-                    ->executeStatement();
-            }
+            $this->removeRelationHashes($currentRelationHashes);
         }
 
         return $result;
     }
 
     /**
-     * Returns the amount of references for the given record
+     * Returns the amount of references for the given record.
      */
     public function getNumberOfReferencedRecords(string $tableName, int $uid): int
     {
@@ -207,6 +185,52 @@ class ReferenceIndex
             )->executeQuery()->fetchOne();
     }
 
+    /**
+     * Get current sys_refindex rows of table:uid from database with hash as index.
+     *
+     * @return array<string, true>
+     */
+    private function getCurrentRelationHashes(string $tableName, int $uid, int $workspaceUid): array
+    {
+        $connection = $this->connectionPool->getConnectionForTable('sys_refindex');
+        $queryBuilder = $connection->createQueryBuilder();
+        $queryBuilder->getRestrictions()->removeAll();
+        $queryResult = $queryBuilder->select('hash')->from('sys_refindex')->where(
+            $queryBuilder->expr()->eq('tablename', $queryBuilder->createNamedParameter($tableName)),
+            $queryBuilder->expr()->eq('recuid', $queryBuilder->createNamedParameter($uid, Connection::PARAM_INT)),
+            $queryBuilder->expr()->eq('workspace', $queryBuilder->createNamedParameter($workspaceUid, Connection::PARAM_INT))
+        )->executeQuery();
+        $currentRelationHashes = [];
+        while ($relation = $queryResult->fetchAssociative()) {
+            $currentRelationHashes[$relation['hash']] = true;
+        }
+        return $currentRelationHashes;
+    }
+
+    /**
+     * Remove sys_refindex rows by hash.
+     *
+     * @param array<string, true> $currentRelationHashes
+     */
+    private function removeRelationHashes(array $currentRelationHashes): void
+    {
+        $connection = $this->connectionPool->getConnectionForTable('sys_refindex');
+        $maxBindParameters = PlatformInformation::getMaxBindParameters($connection->getDatabasePlatform());
+        $chunks = array_chunk(array_keys($currentRelationHashes), $maxBindParameters - 10, true);
+        foreach ($chunks as $chunk) {
+            if (empty($chunk)) {
+                continue;
+            }
+            $queryBuilder = $connection->createQueryBuilder();
+            $queryBuilder
+                ->delete('sys_refindex')
+                ->where(
+                    $queryBuilder->expr()->in('hash', $queryBuilder->createNamedParameter($chunk, Connection::PARAM_STR_ARRAY))
+                )
+                ->executeStatement();
+        }
+    }
+
     /**
      * Calculate the relations for a record of a given table
      *
@@ -348,13 +372,16 @@ class ReferenceIndex
      * @param string $onlyField Specific field to fetch for.
      * @return array Array with information about relations
      * @see export_addRecord()
+     * @internal
      */
     public function getRelations($table, $row, $onlyField = '')
     {
         // Initialize:
         $uid = $row['uid'];
         $outRow = [];
-        foreach ($row as $field => $value) {
+        $relationFields = $this->getTableRelationFields($table);
+        foreach ($relationFields as $field) {
+            $value = $row[$field] ?? null;
             if ($this->shouldExcludeTableColumnFromReferenceIndex($table, $field, $onlyField) === false) {
                 $conf = $GLOBALS['TCA'][$table]['columns'][$field]['config'];
                 // Add a softref definition for link fields if the TCA does not specify one already
@@ -428,6 +455,7 @@ class ReferenceIndex
      * @param string $structurePath Path of value in DS structure
      * @see DataHandler::checkValue_flex_procInData_travDS()
      * @see FlexFormTools::traverseFlexFormXMLData()
+     * @internal
      */
     public function getRelations_flexFormCallBack($dsArr, $dataValue, $PA, $structurePath)
     {
@@ -532,12 +560,6 @@ class ReferenceIndex
         return false;
     }
 
-    /*******************************
-     *
-     * Setting values
-     *
-     *******************************/
-
     /**
      * Setting the value of a reference or removing it completely.
      * Usage: For lowlevel clean up operations!
@@ -558,6 +580,7 @@ class ReferenceIndex
      * @param bool $returnDataArray Return $dataArray only, do not submit it to database.
      * @param bool $bypassWorkspaceAdminCheck If set, it will bypass check for workspace-zero and admin user
      * @return string|bool|array FALSE (=OK), error message string or array (if $returnDataArray is set!)
+     * @internal
      */
     public function setReferenceValue($hash, $newValue, $returnDataArray = false, $bypassWorkspaceAdminCheck = false)
     {
@@ -750,14 +773,8 @@ class ReferenceIndex
         return false;
     }
 
-    /*******************************
-     *
-     * Helper functions
-     *
-     *******************************/
-
     /**
-     * Returns TRUE if the TCA/columns field type is a DB reference field
+     * Returns true if the TCA/columns field type is a DB reference field
      *
      * @param array $configuration Config array for TCA/columns field
      * @return bool TRUE if DB reference field (group/db or select with foreign-table)
@@ -773,10 +790,8 @@ class ReferenceIndex
     }
 
     /**
-     * Returns TRUE if the TCA/columns field type is a reference field
-     *
-     * @param array $configuration Config array for TCA/columns field
-     * @return bool TRUE if reference field
+     * Returns true if the TCA/columns field may carry references. True for
+     * group, inline and friends, for flex, and if there is a 'softref' definition.
      */
     protected function isReferenceField(array $configuration): bool
     {
@@ -790,66 +805,39 @@ class ReferenceIndex
     }
 
     /**
-     * Early check to see if a table has any possible relation fields at all.
-     * This is true if there are columns with type group, select and friends,
-     * or if a table has a column with a 'softref' defined.
+     * List of TCA columns that can have relations. Typically inline, group
+     * and friends, as well as flex fields and fields with 'softref' config.
+     * If empty, the table can not have relations.
+     * Uses a class cache to be quick for multiple calls on same table.
      */
-    protected function hasTableRelationFields(string $tableName): bool
+    private function getTableRelationFields(string $tableName): array
     {
-        if (empty($GLOBALS['TCA'][$tableName]['columns'])) {
-            return false;
-        }
-        foreach ($GLOBALS['TCA'][$tableName]['columns'] as $fieldDefinition) {
-            if (empty($fieldDefinition['config'])) {
-                continue;
-            }
-            if ($this->isReferenceField($fieldDefinition['config'])) {
-                return true;
-            }
-        }
-        return false;
-    }
-
-    /**
-     * Returns all fields of a table which could contain a relation
-     *
-     * @param string $tableName Name of the table
-     * @return array Fields which may contain relations
-     */
-    protected function fetchTableRelationFields(string $tableName): array
-    {
-        if (!empty($this->tableRelationFieldCache[$tableName])) {
+        if (isset($this->tableRelationFieldCache[$tableName])) {
             return $this->tableRelationFieldCache[$tableName];
         }
-        if (!isset($GLOBALS['TCA'][$tableName]['columns'])) {
+        if (!is_array($GLOBALS['TCA'][$tableName]['columns'] ?? false)) {
+            $this->tableRelationFieldCache[$tableName] = [];
             return [];
         }
-        $fields = [];
-        foreach ($GLOBALS['TCA'][$tableName]['columns'] as $field => $fieldDefinition) {
-            if (is_array($fieldDefinition['config'])) {
-                // Check for flex field
-                if (isset($fieldDefinition['config']['type']) && $fieldDefinition['config']['type'] === 'flex') {
-                    // Fetch all fields if the is a field of type flex in the table definition because the complete row is passed to
-                    // FlexFormTools->getDataStructureIdentifier() in the end and might be needed in ds_pointerField or a hook
-                    $this->tableRelationFieldCache[$tableName] = ['*'];
-                    return ['*'];
-                }
-                // Only fetch this field if it can contain a reference
-                if ($this->isReferenceField($fieldDefinition['config'])) {
-                    $fields[] = $field;
-                }
+        $relationFields = [];
+        foreach ($GLOBALS['TCA'][$tableName]['columns'] as $fieldName => $fieldDefinition) {
+            if (!is_array($fieldDefinition['config'] ?? false)) {
+                continue;
+            }
+            if ($this->isReferenceField($fieldDefinition['config'])) {
+                $relationFields[] = $fieldName;
             }
         }
-        $this->tableRelationFieldCache[$tableName] = $fields;
-        return $fields;
+        $this->tableRelationFieldCache[$tableName] = $relationFields;
+        return $relationFields;
     }
 
     /**
-     * Updating Index (External API)
+     * Update full refindex. Used by 'referenceindex:update' CLI and ext:lowlevel BE UI.
      *
      * @param ProgressListenerInterface|null $progressListener If set, the current progress is added to the listener
      * @return array Header and body status content
-     * @todo: Consider moving this together with the helper methods to a dedicated class.
+     * @internal
      */
     public function updateIndex(bool $testOnly, ?ProgressListenerInterface $progressListener = null): array
     {
@@ -898,10 +886,7 @@ class ReferenceIndex
 
             $progressListener?->start($numberOfRecordsInTargetTable, $tableName);
 
-            if ($numberOfRecordsInTargetTable === 0
-                || $this->shouldExcludeTableFromReferenceIndex($tableName)
-                || !$this->hasTableRelationFields($tableName)
-            ) {
+            if ($numberOfRecordsInTargetTable === 0 || $this->shouldExcludeTableFromReferenceIndex($tableName) || empty($this->getTableRelationFields($tableName))) {
                 // Table is empty, should be excluded, or can not have relations. Blindly remove any existing sys_refindex rows.
                 $numberOfHandledRecords += $numberOfRecordsInTargetTable;
                 $queryBuilder = $this->connectionPool->getQueryBuilderForTable('sys_refindex');
@@ -1077,16 +1062,11 @@ class ReferenceIndex
                 }
             }
 
-            $fields = ['uid'];
-            if (BackendUtility::isTableWorkspaceEnabled($tableName)) {
-                $fields[] = 't3ver_wsid';
-            }
-
-            // Traverse all records in tables, not including soft-deleted records
+            // Traverse all records in table, not including soft-deleted records
             $queryBuilder = $this->connectionPool->getQueryBuilderForTable($tableName);
             $queryBuilder->getRestrictions()->removeAll()->add(GeneralUtility::makeInstance(DeletedRestriction::class));
             $queryResult = $queryBuilder
-                ->select(...$fields)
+                ->select('*')
                 ->from($tableName)
                 ->orderBy('uid')
                 ->executeQuery();
@@ -1098,7 +1078,7 @@ class ReferenceIndex
                     // Modify addCategoryRelation as example. In those cases, we need to iterate all active workspaces
                     // and update refindex for all foreign workspace records that point to it.
                     foreach ($listOfActiveWorkspaces as $workspaceId) {
-                        $result = $this->updateRefIndexTable($tableName, (int)$record['uid'], $testOnly, $workspaceId);
+                        $result = $this->updateRefIndexTable($tableName, (int)$record['uid'], $testOnly, $workspaceId, $record);
                         $numberOfHandledRecords++;
                         if ($result['addedNodes'] || $result['deletedNodes']) {
                             $error = 'Record ' . $tableName . ':' . $record['uid'] . ' had ' . $result['addedNodes'] . ' added indexes and ' . $result['deletedNodes'] . ' deleted indexes';
@@ -1107,7 +1087,7 @@ class ReferenceIndex
                         }
                     }
                 } else {
-                    $result = $this->updateRefIndexTable($tableName, (int)$record['uid'], $testOnly, (int)($record['t3ver_wsid'] ?? 0));
+                    $result = $this->updateRefIndexTable($tableName, (int)$record['uid'], $testOnly, (int)($record['t3ver_wsid'] ?? 0), $record);
                     $numberOfHandledRecords++;
                     if ($result['addedNodes'] || $result['deletedNodes']) {
                         $error = 'Record ' . $tableName . ':' . $record['uid'] . ' had ' . $result['addedNodes'] . ' added indexes and ' . $result['deletedNodes'] . ' deleted indexes';
@@ -1226,52 +1206,6 @@ class ReferenceIndex
             ->executeStatement();
     }
 
-    /**
-     * Get one record from database.
-     *
-     * @return array|false
-     */
-    protected function getRecord(string $tableName, int $uid)
-    {
-        // Fetch fields of the table which might contain relations
-        $tableRelationFields = $this->fetchTableRelationFields($tableName);
-
-        if ($tableRelationFields === []) {
-            // Return if there are no fields which could contain relations
-            return $this->relations;
-        }
-        if ($tableRelationFields !== ['*']) {
-            // Only fields that might contain relations are fetched
-            $tableRelationFields[] = 'uid';
-            if (BackendUtility::isTableWorkspaceEnabled($tableName)) {
-                $tableRelationFields = array_merge($tableRelationFields, ['t3ver_wsid', 't3ver_state']);
-            }
-        }
-
-        $queryBuilder = $this->connectionPool->getQueryBuilderForTable($tableName);
-        $queryBuilder->getRestrictions()->removeAll();
-        $queryBuilder
-            ->select(...array_unique($tableRelationFields))
-            ->from($tableName)
-            ->where(
-                $queryBuilder->expr()->eq(
-                    'uid',
-                    $queryBuilder->createNamedParameter($uid, Connection::PARAM_INT)
-                )
-            );
-        // Do not fetch soft deleted records
-        $deleteField = (string)($GLOBALS['TCA'][$tableName]['ctrl']['delete'] ?? '');
-        if ($deleteField !== '') {
-            $queryBuilder->andWhere(
-                $queryBuilder->expr()->eq(
-                    $deleteField,
-                    $queryBuilder->createNamedParameter(0, Connection::PARAM_INT)
-                )
-            );
-        }
-        return $queryBuilder->executeQuery()->fetchAssociative();
-    }
-
     /**
      * Checks if a given table should be excluded from ReferenceIndex
      */
diff --git a/typo3/sysext/impexp/Tests/Functional/Fixtures/XmlExports/pages-and-ttcontent-with-corrupt-image.xml b/typo3/sysext/impexp/Tests/Functional/Fixtures/XmlExports/pages-and-ttcontent-with-corrupt-image.xml
index cdf5d2e10407..72e02441e299 100644
--- a/typo3/sysext/impexp/Tests/Functional/Fixtures/XmlExports/pages-and-ttcontent-with-corrupt-image.xml
+++ b/typo3/sysext/impexp/Tests/Functional/Fixtures/XmlExports/pages-and-ttcontent-with-corrupt-image.xml
@@ -239,34 +239,34 @@
 				<field index="header_link">file:1</field>
 			</fieldlist>
 			<related index="rels" type="array">
-				<field index="image" type="array">
-					<type>db</type>
-					<relations index="itemArray" type="array">
-						<element index="0" type="array">
-							<id>1</id>
-							<table>sys_file_reference</table>
-						</element>
-					</relations>
-				</field>
 				<field index="header_link" type="array">
-					<softrefs type="array">
-						<keys type="array">
-							<softref_key index="typolink" type="array">
-								<softref_element index="2487ce518ed56d22f20f259928ff43f1:0" type="array">
-									<matchString>file:1</matchString>
-									<subst type="array">
-										<type>db</type>
-										<recordRef>sys_file:1</recordRef>
-										<tokenID>2487ce518ed56d22f20f259928ff43f1</tokenID>
-										<tokenValue>file:1</tokenValue>
-									</subst>
-								</softref_element>
-							</softref_key>
-						</keys>
-						<tokenizedContent>{softref:2487ce518ed56d22f20f259928ff43f1}</tokenizedContent>
-					</softrefs>
-				</field>
-			</related>
+                    <softrefs type="array">
+                        <keys type="array">
+                            <softref_key index="typolink" type="array">
+                                <softref_element index="2487ce518ed56d22f20f259928ff43f1:0" type="array">
+                                    <matchString>file:1</matchString>
+                                    <subst type="array">
+                                        <type>db</type>
+                                        <recordRef>sys_file:1</recordRef>
+                                        <tokenID>2487ce518ed56d22f20f259928ff43f1</tokenID>
+                                        <tokenValue>file:1</tokenValue>
+                                    </subst>
+                                </softref_element>
+                            </softref_key>
+                        </keys>
+                        <tokenizedContent>{softref:2487ce518ed56d22f20f259928ff43f1}</tokenizedContent>
+                    </softrefs>
+                </field>
+                <field index="image" type="array">
+                    <type>db</type>
+                    <relations index="itemArray" type="array">
+                        <element index="0" type="array">
+                            <id>1</id>
+                            <table>sys_file_reference</table>
+                        </element>
+                    </relations>
+                </field>
+            </related>
 		</tablerow>
 		<tablerow index="pages:3" type="array">
 			<fieldlist index="data" type="array">
diff --git a/typo3/sysext/impexp/Tests/Functional/Fixtures/XmlExports/pages-and-ttcontent-with-image-but-not-included.xml b/typo3/sysext/impexp/Tests/Functional/Fixtures/XmlExports/pages-and-ttcontent-with-image-but-not-included.xml
index f2dc7f99d179..40c681f70662 100644
--- a/typo3/sysext/impexp/Tests/Functional/Fixtures/XmlExports/pages-and-ttcontent-with-image-but-not-included.xml
+++ b/typo3/sysext/impexp/Tests/Functional/Fixtures/XmlExports/pages-and-ttcontent-with-image-but-not-included.xml
@@ -239,34 +239,34 @@
 				<field index="header_link">file:1</field>
 			</fieldlist>
 			<related index="rels" type="array">
-				<field index="image" type="array">
-					<type>db</type>
-					<relations index="itemArray" type="array">
-						<element index="0" type="array">
-							<id>1</id>
-							<table>sys_file_reference</table>
-						</element>
-					</relations>
-				</field>
 				<field index="header_link" type="array">
-					<softrefs type="array">
-						<keys type="array">
-							<softref_key index="typolink" type="array">
-								<softref_element index="2487ce518ed56d22f20f259928ff43f1:0" type="array">
-									<matchString>file:1</matchString>
-									<subst type="array">
-										<type>db</type>
-										<recordRef>sys_file:1</recordRef>
-										<tokenID>2487ce518ed56d22f20f259928ff43f1</tokenID>
-										<tokenValue>file:1</tokenValue>
-									</subst>
-								</softref_element>
-							</softref_key>
-						</keys>
-						<tokenizedContent>{softref:2487ce518ed56d22f20f259928ff43f1}</tokenizedContent>
-					</softrefs>
-				</field>
-			</related>
+                    <softrefs type="array">
+                        <keys type="array">
+                            <softref_key index="typolink" type="array">
+                                <softref_element index="2487ce518ed56d22f20f259928ff43f1:0" type="array">
+                                    <matchString>file:1</matchString>
+                                    <subst type="array">
+                                        <type>db</type>
+                                        <recordRef>sys_file:1</recordRef>
+                                        <tokenID>2487ce518ed56d22f20f259928ff43f1</tokenID>
+                                        <tokenValue>file:1</tokenValue>
+                                    </subst>
+                                </softref_element>
+                            </softref_key>
+                        </keys>
+                        <tokenizedContent>{softref:2487ce518ed56d22f20f259928ff43f1}</tokenizedContent>
+                    </softrefs>
+                </field>
+                <field index="image" type="array">
+                    <type>db</type>
+                    <relations index="itemArray" type="array">
+                        <element index="0" type="array">
+                            <id>1</id>
+                            <table>sys_file_reference</table>
+                        </element>
+                    </relations>
+                </field>
+            </related>
 		</tablerow>
 		<tablerow index="pages:3" type="array">
 			<fieldlist index="data" type="array">
diff --git a/typo3/sysext/impexp/Tests/Functional/Fixtures/XmlExports/pages-and-ttcontent-with-image.xml b/typo3/sysext/impexp/Tests/Functional/Fixtures/XmlExports/pages-and-ttcontent-with-image.xml
index 62dfe8d15f79..9750f0e19d49 100644
--- a/typo3/sysext/impexp/Tests/Functional/Fixtures/XmlExports/pages-and-ttcontent-with-image.xml
+++ b/typo3/sysext/impexp/Tests/Functional/Fixtures/XmlExports/pages-and-ttcontent-with-image.xml
@@ -239,34 +239,34 @@
 				<field index="header_link">file:1</field>
 			</fieldlist>
 			<related index="rels" type="array">
-				<field index="image" type="array">
-					<type>db</type>
-					<relations index="itemArray" type="array">
-						<element index="0" type="array">
-							<id>1</id>
-							<table>sys_file_reference</table>
-						</element>
-					</relations>
-				</field>
 				<field index="header_link" type="array">
-					<softrefs type="array">
-						<keys type="array">
-							<softref_key index="typolink" type="array">
-								<softref_element index="2487ce518ed56d22f20f259928ff43f1:0" type="array">
-									<matchString>file:1</matchString>
-									<subst type="array">
-										<type>db</type>
-										<recordRef>sys_file:1</recordRef>
-										<tokenID>2487ce518ed56d22f20f259928ff43f1</tokenID>
-										<tokenValue>file:1</tokenValue>
-									</subst>
-								</softref_element>
-							</softref_key>
-						</keys>
-						<tokenizedContent>{softref:2487ce518ed56d22f20f259928ff43f1}</tokenizedContent>
-					</softrefs>
-				</field>
-			</related>
+                    <softrefs type="array">
+                        <keys type="array">
+                            <softref_key index="typolink" type="array">
+                                <softref_element index="2487ce518ed56d22f20f259928ff43f1:0" type="array">
+                                    <matchString>file:1</matchString>
+                                    <subst type="array">
+                                        <type>db</type>
+                                        <recordRef>sys_file:1</recordRef>
+                                        <tokenID>2487ce518ed56d22f20f259928ff43f1</tokenID>
+                                        <tokenValue>file:1</tokenValue>
+                                    </subst>
+                                </softref_element>
+                            </softref_key>
+                        </keys>
+                        <tokenizedContent>{softref:2487ce518ed56d22f20f259928ff43f1}</tokenizedContent>
+                    </softrefs>
+                </field>
+                <field index="image" type="array">
+                    <type>db</type>
+                    <relations index="itemArray" type="array">
+                        <element index="0" type="array">
+                            <id>1</id>
+                            <table>sys_file_reference</table>
+                        </element>
+                    </relations>
+                </field>
+            </related>
 		</tablerow>
 		<tablerow index="pages:3" type="array">
 			<fieldlist index="data" type="array">
diff --git a/typo3/sysext/impexp/Tests/Functional/Fixtures/XmlExports/pages-and-ttcontent-with-softrefs.xml b/typo3/sysext/impexp/Tests/Functional/Fixtures/XmlExports/pages-and-ttcontent-with-softrefs.xml
index 18cfdab0032c..c87864a37d0a 100644
--- a/typo3/sysext/impexp/Tests/Functional/Fixtures/XmlExports/pages-and-ttcontent-with-softrefs.xml
+++ b/typo3/sysext/impexp/Tests/Functional/Fixtures/XmlExports/pages-and-ttcontent-with-softrefs.xml
@@ -52,29 +52,29 @@
 					<title>Test content</title>
 					<relations index="rels" type="array"></relations>
 					<softrefs type="array">
-						<softref_element index="pi_flexform:sDEF/lDEF/softrefLink/vDEF/:typolink:97bfc48409f1287b86193f36dbc5ea89:0" type="array">
-							<field>pi_flexform</field>
-							<spKey>typolink</spKey>
-							<structurePath>sDEF/lDEF/softrefLink/vDEF/</structurePath>
-							<matchString>t3://page?uid=2</matchString>
-							<subst type="array">
-								<type>db</type>
-								<recordRef>pages:2</recordRef>
-								<tokenID>97bfc48409f1287b86193f36dbc5ea89</tokenID>
-								<tokenValue>2</tokenValue>
-							</subst>
-						</softref_element>
-						<softref_element index="header_link:typolink:2487ce518ed56d22f20f259928ff43f1:0" type="array">
-							<field>header_link</field>
-							<spKey>typolink</spKey>
-							<matchString>file:1</matchString>
-							<subst type="array">
-								<type>db</type>
-								<recordRef>sys_file:1</recordRef>
-								<tokenID>2487ce518ed56d22f20f259928ff43f1</tokenID>
-								<tokenValue>file:1</tokenValue>
-							</subst>
-						</softref_element>
+                        <softref_element index="header_link:typolink:2487ce518ed56d22f20f259928ff43f1:0" type="array">
+                            <field>header_link</field>
+                            <spKey>typolink</spKey>
+                            <matchString>file:1</matchString>
+                            <subst type="array">
+                                <type>db</type>
+                                <recordRef>sys_file:1</recordRef>
+                                <tokenID>2487ce518ed56d22f20f259928ff43f1</tokenID>
+                                <tokenValue>file:1</tokenValue>
+                            </subst>
+                        </softref_element>
+                        <softref_element index="pi_flexform:sDEF/lDEF/softrefLink/vDEF/:typolink:97bfc48409f1287b86193f36dbc5ea89:0" type="array">
+                            <field>pi_flexform</field>
+                            <spKey>typolink</spKey>
+                            <structurePath>sDEF/lDEF/softrefLink/vDEF/</structurePath>
+                            <matchString>t3://page?uid=2</matchString>
+                            <subst type="array">
+                                <type>db</type>
+                                <recordRef>pages:2</recordRef>
+                                <tokenID>97bfc48409f1287b86193f36dbc5ea89</tokenID>
+                                <tokenValue>2</tokenValue>
+                            </subst>
+                        </softref_element>
 					</softrefs>
 				</rec>
 			</table>
@@ -147,6 +147,24 @@
 				<field index="header_link">file:1</field>
 			</fieldlist>
 			<related index="rels" type="array">
+                <field index="header_link" type="array">
+                    <softrefs type="array">
+                        <keys type="array">
+                            <softref_key index="typolink" type="array">
+                                <softref_element index="2487ce518ed56d22f20f259928ff43f1:0" type="array">
+                                    <matchString>file:1</matchString>
+                                    <subst type="array">
+                                        <type>db</type>
+                                        <recordRef>sys_file:1</recordRef>
+                                        <tokenID>2487ce518ed56d22f20f259928ff43f1</tokenID>
+                                        <tokenValue>file:1</tokenValue>
+                                    </subst>
+                                </softref_element>
+                            </softref_key>
+                        </keys>
+                        <tokenizedContent>{softref:2487ce518ed56d22f20f259928ff43f1}</tokenizedContent>
+                    </softrefs>
+                </field>
 				<field index="pi_flexform" type="array">
 					<type>flex</type>
 					<flexform index="flexFormRels" type="array">
@@ -171,24 +189,6 @@
 						</softref_relations>
 					</flexform>
 				</field>
-				<field index="header_link" type="array">
-					<softrefs type="array">
-						<keys type="array">
-							<softref_key index="typolink" type="array">
-								<softref_element index="2487ce518ed56d22f20f259928ff43f1:0" type="array">
-									<matchString>file:1</matchString>
-									<subst type="array">
-										<type>db</type>
-										<recordRef>sys_file:1</recordRef>
-										<tokenID>2487ce518ed56d22f20f259928ff43f1</tokenID>
-										<tokenValue>file:1</tokenValue>
-									</subst>
-								</softref_element>
-							</softref_key>
-						</keys>
-						<tokenizedContent>{softref:2487ce518ed56d22f20f259928ff43f1}</tokenizedContent>
-					</softrefs>
-				</field>
 			</related>
 		</tablerow>
 		<tablerow index="pages:3" type="array">
-- 
GitLab