From 2b8123d07197a61cac0185899e0a0f144f66d0ec Mon Sep 17 00:00:00 2001
From: Benni Mack <benni@typo3.org>
Date: Sat, 5 Nov 2022 22:11:59 +0100
Subject: [PATCH] [BUGFIX] Speed up FormEngine with selective workspaceOL()
 calls
MIME-Version: 1.0
Content-Type: text/plain; charset=UTF-8
Content-Transfer-Encoding: 8bit

BackendUtility::getPossibleWorkspaceVersionIdsOfLiveRecordIds()
is added to check for workspace versions within FormEngine
foreign_field logic to first fetch a list of all records
that have a versioned record within the workspace, and only
apply BackendUtility::workspaceOL() for these cases.

Without the patch, each live record triggers a database
query to resolve a possible workspace overlay. With the
patch, one query is done to see which records have
workspace overlays, only those are queried for overlays.
This reduces the number of performed queries a lot, since
typically only a small number of records are overlaid.

This only affects editing records in a workspace.

Resolves: #99007
Releases: main, 11.5
Change-Id: Ia7c47e5de0082ca0ff2c68b30d9ff1bc5312981b
Reviewed-on: https://review.typo3.org/c/Packages/TYPO3.CMS/+/76446
Tested-by: Christian Kuhn <lolli@schwarzbu.ch>
Reviewed-by: Christian Kuhn <lolli@schwarzbu.ch>
Tested-by: core-ci <typo3@b13.com>
Reviewed-by: Andreas Fernandez <a.fernandez@scripting-base.de>
Tested-by: Stefan Bürk <stefan@buerk.tech>
Reviewed-by: Stefan Bürk <stefan@buerk.tech>
Tested-by: Benni Mack <benni@typo3.org>
Reviewed-by: Benni Mack <benni@typo3.org>
---
 .../FormDataProvider/AbstractItemProvider.php |  13 +-
 .../Classes/Utility/BackendUtility.php        |  37 +++++
 .../FormDataProvider/TcaSelectItemsTest.php   | 143 ++++++++++--------
 .../TcaSelectTreeItemsTest.php                |   2 +-
 4 files changed, 126 insertions(+), 69 deletions(-)

diff --git a/typo3/sysext/backend/Classes/Form/FormDataProvider/AbstractItemProvider.php b/typo3/sysext/backend/Classes/Form/FormDataProvider/AbstractItemProvider.php
index a61da37d3ff0..2151e24f8d18 100644
--- a/typo3/sysext/backend/Classes/Form/FormDataProvider/AbstractItemProvider.php
+++ b/typo3/sysext/backend/Classes/Form/FormDataProvider/AbstractItemProvider.php
@@ -294,8 +294,17 @@ abstract class AbstractItemProvider
         $fileRepository = GeneralUtility::makeInstance(FileRepository::class);
         $iconFactory = GeneralUtility::makeInstance(IconFactory::class);
 
-        while ($foreignRow = $queryResult->fetchAssociative()) {
-            BackendUtility::workspaceOL($foreignTable, $foreignRow);
+        $allForeignRows = $queryResult->fetchAllAssociative();
+        // Find all possible versioned records of the current IDs, so we do not need to overlay each record
+        // This way, workspaceOL() does not need to be called for each record.
+        $workspaceId = $this->getBackendUser()->workspace;
+        $doOverlaysForRecords = BackendUtility::getPossibleWorkspaceVersionIdsOfLiveRecordIds($foreignTable, array_column($allForeignRows, 'uid'), $workspaceId);
+
+        foreach ($allForeignRows as $foreignRow) {
+            // Only do workspace overlays when a versioned record exists.
+            if (isset($foreignRow['uid']) && isset($doOverlaysForRecords[(int)$foreignRow['uid']])) {
+                BackendUtility::workspaceOL($foreignTable, $foreignRow, $workspaceId);
+            }
             // Only proceed in case the row was not unset and we don't deal with a delete placeholder
             if (is_array($foreignRow)
                 && !VersionState::cast($foreignRow['t3ver_state'] ?? 0)->equals(VersionState::DELETE_PLACEHOLDER)
diff --git a/typo3/sysext/backend/Classes/Utility/BackendUtility.php b/typo3/sysext/backend/Classes/Utility/BackendUtility.php
index 07a5fefd7c26..4fd641d6bf88 100644
--- a/typo3/sysext/backend/Classes/Utility/BackendUtility.php
+++ b/typo3/sysext/backend/Classes/Utility/BackendUtility.php
@@ -33,6 +33,7 @@ use TYPO3\CMS\Core\Context\DateTimeAspect;
 use TYPO3\CMS\Core\Core\Environment;
 use TYPO3\CMS\Core\Database\Connection;
 use TYPO3\CMS\Core\Database\ConnectionPool;
+use TYPO3\CMS\Core\Database\Platform\PlatformInformation;
 use TYPO3\CMS\Core\Database\Query\QueryBuilder;
 use TYPO3\CMS\Core\Database\Query\QueryHelper;
 use TYPO3\CMS\Core\Database\Query\Restriction\DeletedRestriction;
@@ -3099,6 +3100,42 @@ class BackendUtility
         return false;
     }
 
+    /**
+     * Use this function if you have a large set of IDs to find out which ones have a counterpart within a workspace.
+     * Within a workspace, this is one additional query, so use it only if you have a set of > 2 to find out if you
+     * really need to call BackendUtility::workspaceOL() all the time.
+     *
+     * If you have 1000 records, but only have two 2 records which have been modified in a workspace, only 2 items
+     * are returned.
+     *
+     * @return array<int, int> keys contain the live record ID, values the versioned record ID
+     * @internal this method is not public API and might change, as you really should know what you are doing.
+     */
+    public static function getPossibleWorkspaceVersionIdsOfLiveRecordIds(string $table, array $liveRecordIds, int $workspaceId): array
+    {
+        if ($liveRecordIds === [] || $workspaceId === 0 || !self::isTableWorkspaceEnabled($table)) {
+            return [];
+        }
+        $doOverlaysForRecords = [];
+        $connection = self::getConnectionForTable($table);
+        $maxChunk = PlatformInformation::getMaxBindParameters($connection->getDatabasePlatform());
+        foreach (array_chunk($liveRecordIds, (int)floor($maxChunk / 2)) as $liveRecordIdChunk) {
+            $queryBuilder = GeneralUtility::makeInstance(ConnectionPool::class)->getQueryBuilderForTable($table);
+            $doOverlaysForRecordsStatement = $queryBuilder
+                ->select('t3ver_oid', 'uid')
+                ->from($table)
+                ->where(
+                    $queryBuilder->expr()->eq('t3ver_wsid', $queryBuilder->createNamedParameter($workspaceId, Connection::PARAM_INT)),
+                    $queryBuilder->expr()->in('t3ver_oid', $queryBuilder->quoteArrayBasedValueListToIntegerList($liveRecordIdChunk))
+                )
+                ->executeQuery();
+            while ($recordWithVersionedRecord = $doOverlaysForRecordsStatement->fetchNumeric()) {
+                $doOverlaysForRecords[(int)$recordWithVersionedRecord[0]] = (int)$recordWithVersionedRecord[1];
+            }
+        }
+        return $doOverlaysForRecords;
+    }
+
     /**
      * Returns live version of record
      *
diff --git a/typo3/sysext/backend/Tests/Unit/Form/FormDataProvider/TcaSelectItemsTest.php b/typo3/sysext/backend/Tests/Unit/Form/FormDataProvider/TcaSelectItemsTest.php
index a141d51795e3..1b63b3adb8ff 100644
--- a/typo3/sysext/backend/Tests/Unit/Form/FormDataProvider/TcaSelectItemsTest.php
+++ b/typo3/sysext/backend/Tests/Unit/Form/FormDataProvider/TcaSelectItemsTest.php
@@ -126,7 +126,7 @@ class TcaSelectItemsTest extends UnitTestCase
         [$queryBuilderProphet, $connectionPoolProphet] = $this->mockDatabaseConnection('foreignTable');
 
         $statementProphet = $this->prophesize(Result::class);
-        $statementProphet->fetchAssociative()->shouldBeCalled();
+        $statementProphet->fetchAllAssociative()->shouldBeCalled();
 
         $queryBuilderProphet->select('foreignTable.uid')
             ->shouldBeCalled()
@@ -1002,6 +1002,7 @@ class TcaSelectItemsTest extends UnitTestCase
         [$queryBuilderProphet, $connectionPoolProphet] = $this->mockDatabaseConnection();
 
         $statementProphet = $this->prophesize(Result::class);
+        $statementProphet->fetchAllAssociative()->shouldBeCalled()->willReturn([]);
 
         $queryBuilderProphet->select('fTable.uid')->shouldBeCalled()->willReturn($queryBuilderProphet->reveal());
         $queryBuilderProphet->from('fTable')->shouldBeCalled()->willReturn($queryBuilderProphet->reveal());
@@ -1096,6 +1097,7 @@ class TcaSelectItemsTest extends UnitTestCase
         [$queryBuilderProphet, $connectionPoolProphet] = $this->mockDatabaseConnection();
 
         $statementProphet = $this->prophesize(Result::class);
+        $statementProphet->fetchAllAssociative()->shouldBeCalled()->willReturn([]);
 
         $queryBuilderProphet->select('fTable.uid')->shouldBeCalled()->willReturn($queryBuilderProphet->reveal());
         $queryBuilderProphet->from('fTable')->shouldBeCalled()->willReturn($queryBuilderProphet->reveal());
@@ -1264,19 +1266,20 @@ class TcaSelectItemsTest extends UnitTestCase
         GeneralUtility::addInstance(ConnectionPool::class, $connectionPoolProphet->reveal());
         GeneralUtility::addInstance(ConnectionPool::class, $connectionPoolProphet->reveal());
 
-        $counter = 0;
-        $statementProphet->fetchAssociative()->shouldBeCalled()->will(static function ($args) use (&$counter) {
-            $counter++;
-            if ($counter >= 3) {
-                return false;
-            }
-            return [
-                'uid' => $counter,
+        $statementProphet->fetchAllAssociative()->shouldBeCalled()->willReturn([
+            [
+                'uid' => 1,
                 'pid' => 23,
                 'labelField' => 'aLabel',
                 'aValue' => 'bar,',
-            ];
-        });
+            ],
+            [
+                'uid' => 2,
+                'pid' => 23,
+                'labelField' => 'aLabel',
+                'aValue' => 'bar,',
+            ],
+        ]);
 
         $expected = $input;
         $expected['processedTca']['columns']['aField']['config']['items'] = [
@@ -1372,18 +1375,23 @@ class TcaSelectItemsTest extends UnitTestCase
         GeneralUtility::addInstance(ConnectionPool::class, $connectionPoolProphet->reveal());
         GeneralUtility::addInstance(ConnectionPool::class, $connectionPoolProphet->reveal());
 
-        $counter = 0;
-        $statementProphet->fetchAssociative()->shouldBeCalled()->will(static function ($args) use (&$counter) {
-            $counter++;
-            if ($counter >= 3) {
-                return false;
-            }
-            return [
-                'uid' => $counter,
+        $statementProphet->fetchAllAssociative()->shouldBeCalled()->willReturn([
+            [
+                'uid' => 1,
                 'pid' => 0,
                 'labelField' => 'storageFolderLabel',
-            ];
-        });
+            ],
+            [
+                'uid' => 2,
+                'pid' => 0,
+                'labelField' => 'storageFolderLabel',
+            ],
+            [
+                'uid' => 3,
+                'pid' => 0,
+                'labelField' => 'storageFolderLabel',
+            ],
+        ]);
 
         $expected = $input;
         $expected['processedTca']['columns']['aField']['config']['items'] = [
@@ -1469,7 +1477,7 @@ class TcaSelectItemsTest extends UnitTestCase
             'pid' => 23,
             'icon' => 'foo.jpg',
         ];
-        $statementProphet->fetchAssociative()->shouldBeCalled()->willReturn($foreignTableRowResultOne, false);
+        $statementProphet->fetchAllAssociative()->shouldBeCalled()->willReturn([$foreignTableRowResultOne]);
 
         $expected = $input;
         $expected['processedTca']['columns']['aField']['config']['items'] = [
@@ -2111,20 +2119,22 @@ class TcaSelectItemsTest extends UnitTestCase
         GeneralUtility::addInstance(ConnectionPool::class, $connectionPoolProphet->reveal());
         GeneralUtility::addInstance(ConnectionPool::class, $connectionPoolProphet->reveal());
 
-        $counter = 0;
-        $statementProphet->fetchAssociative()->shouldBeCalled()->will(static function ($args) use (&$counter) {
-            $counter++;
-            if ($counter >= 3) {
-                return false;
-            }
-            return [
-                'uid' => $counter,
+        $statementProphet->fetchAllAssociative()->willReturn([
+            [
+                'uid' => 1,
                 'pid' => 23,
-                'labelField' => 'aLabel_' . $counter,
+                'labelField' => 'aLabel_1',
                 'aValue' => 'bar,',
                 'dbfield' => 'some data',
-            ];
-        });
+            ],
+            [
+                'uid' => 2,
+                'pid' => 23,
+                'labelField' => 'aLabel_2',
+                'aValue' => 'bar,',
+                'dbfield' => 'some data',
+            ],
+        ]);
 
         $expected = $input;
         $expected['processedTca']['columns']['aField']['config']['items'] = [
@@ -2231,22 +2241,23 @@ class TcaSelectItemsTest extends UnitTestCase
         GeneralUtility::addInstance(ConnectionPool::class, $connectionPoolProphet->reveal());
         GeneralUtility::addInstance(ConnectionPool::class, $connectionPoolProphet->reveal());
 
-        $counter = 0;
-
         // simulates foreign_table containing two rows
-        $statementProphet->fetchAssociative()->shouldBeCalled()->will(static function ($args) use (&$counter) {
-            $counter++;
-            if ($counter >= 3) {
-                return false;
-            }
-            return [
-                'uid' => $counter,
+        $statementProphet->fetchAllAssociative()->shouldBeCalled()->willReturn([
+            [
+                'uid' => 1,
                 'pid' => 23,
                 'labelField' => 'aLabel will be replaced since the label column is not configured',
                 'aValue' => 'bar, irrelevant',
                 'dbfield' => 'some random data, irrelevant',
-            ];
-        });
+            ],
+            [
+                'uid' => 2,
+                'pid' => 23,
+                'labelField' => 'aLabel will be replaced since the label column is not configured',
+                'aValue' => 'bar, irrelevant',
+                'dbfield' => 'some random data, irrelevant',
+            ],
+        ]);
 
         $expected = $input;
         $expected['processedTca']['columns']['aField']['config'] = [
@@ -2360,21 +2371,21 @@ class TcaSelectItemsTest extends UnitTestCase
         GeneralUtility::addInstance(ConnectionPool::class, $connectionPoolProphet->reveal());
         GeneralUtility::addInstance(ConnectionPool::class, $connectionPoolProphet->reveal());
 
-        $counter = 0;
-
         // simulates foreign_table containing two rows
-        $statementProphet->fetchAssociative()->shouldBeCalled()->will(static function ($args) use (&$counter) {
-            $counter++;
-            if ($counter >= 3) {
-                return false;
-            }
-            return [
-                'uid' => $counter,
+        $statementProphet->fetchAllAssociative()->shouldBeCalled()->willReturn([
+            [
+                'uid' => 1,
                 'pid' => 23,
                 'labelField' => 'aLabel will be replaced since the label column is not configured',
                 'randomDbField' => 'bar, irrelevant',
-            ];
-        });
+            ],
+            [
+                'uid' => 2,
+                'pid' => 23,
+                'labelField' => 'aLabel will be replaced since the label column is not configured',
+                'randomDbField' => 'bar, irrelevant',
+            ],
+        ]);
 
         $expected = $input;
         $expected['processedTca']['columns']['aField']['config'] = [
@@ -2490,21 +2501,21 @@ class TcaSelectItemsTest extends UnitTestCase
         GeneralUtility::addInstance(ConnectionPool::class, $connectionPoolProphet->reveal());
         GeneralUtility::addInstance(ConnectionPool::class, $connectionPoolProphet->reveal());
 
-        $counter = 0;
-
         // simulates foreign_table containing two rows
-        $statementProphet->fetchAssociative()->shouldBeCalled()->will(static function ($args) use (&$counter) {
-            $counter++;
-            if ($counter >= 3) {
-                return false;
-            }
-            return [
-                'uid' => $counter,
+        $statementProphet->fetchAllAssociative()->shouldBeCalled()->willReturn([
+            [
+                'uid' => 1,
                 'pid' => 23,
                 'labelField' => 'aLabel will be replaced since the label column is not configured',
                 'randomDbField' => 'bar, irrelevant',
-            ];
-        });
+            ],
+            [
+                'uid' => 2,
+                'pid' => 23,
+                'labelField' => 'aLabel will be replaced since the label column is not configured',
+                'randomDbField' => 'bar, irrelevant',
+            ],
+        ]);
 
         $expected = $input;
         $expected['processedTca']['columns']['aField']['config'] = [
diff --git a/typo3/sysext/backend/Tests/Unit/Form/FormDataProvider/TcaSelectTreeItemsTest.php b/typo3/sysext/backend/Tests/Unit/Form/FormDataProvider/TcaSelectTreeItemsTest.php
index 3850b77de2c8..82cacca8567c 100644
--- a/typo3/sysext/backend/Tests/Unit/Form/FormDataProvider/TcaSelectTreeItemsTest.php
+++ b/typo3/sysext/backend/Tests/Unit/Form/FormDataProvider/TcaSelectTreeItemsTest.php
@@ -64,7 +64,7 @@ class TcaSelectTreeItemsTest extends UnitTestCase
         });
 
         $statementProphet = $this->prophesize(Result::class);
-        $statementProphet->fetchAssociative()->shouldBeCalled();
+        $statementProphet->fetchAllAssociative()->shouldBeCalled();
 
         $restrictionProphet = $this->prophesize(DefaultRestrictionContainer::class);
         $restrictionProphet->removeAll()->willReturn($restrictionProphet->reveal());
-- 
GitLab