1<?php
2
3declare(strict_types=1);
4
5/*
6 * This file is part of the TYPO3 CMS project.
7 *
8 * It is free software; you can redistribute it and/or modify it under
9 * the terms of the GNU General Public License, either version 2
10 * of the License, or any later version.
11 *
12 * For the full copyright and license information, please read the
13 * LICENSE.txt file that was distributed with this source code.
14 *
15 * The TYPO3 project - inspiring people to share!
16 */
17
18namespace TYPO3\CMS\Install\Updates;
19
20use Doctrine\DBAL\Platforms\SQLServerPlatform;
21use TYPO3\CMS\Core\Database\Connection;
22use TYPO3\CMS\Core\Database\ConnectionPool;
23use TYPO3\CMS\Core\Registry;
24use TYPO3\CMS\Core\Utility\GeneralUtility;
25use TYPO3\CMS\Install\Updates\RowUpdater\RowUpdaterInterface;
26use TYPO3\CMS\Install\Updates\RowUpdater\WorkspaceVersionRecordsMigration;
27
28/**
29 * This is a generic updater to migrate content of TCA rows.
30 *
31 * Multiple classes implementing interface "RowUpdateInterface" can be
32 * registered here, each for a specific update purpose.
33 *
34 * The updater fetches each row of all TCA registered tables and
35 * visits the client classes who may modify the row content.
36 *
37 * The updater remembers for each class if it run through, so the updater
38 * will be shown again if a new updater class is registered that has not
39 * been run yet.
40 *
41 * A start position pointer is stored in the registry that is updated during
42 * the run process, so if for instance the PHP process runs into a timeout,
43 * the job can restart at the position it stopped.
44 * @internal This class is only meant to be used within EXT:install and is not part of the TYPO3 Core API.
45 */
46class DatabaseRowsUpdateWizard implements UpgradeWizardInterface, RepeatableInterface
47{
48    /**
49     * @var array Single classes that may update rows
50     */
51    protected $rowUpdater = [
52        WorkspaceVersionRecordsMigration::class,
53    ];
54
55    /**
56     * @internal
57     * @return string[]
58     */
59    public function getAvailableRowUpdater(): array
60    {
61        return $this->rowUpdater;
62    }
63
64    /**
65     * @return string Unique identifier of this updater
66     */
67    public function getIdentifier(): string
68    {
69        return 'databaseRowsUpdateWizard';
70    }
71
72    /**
73     * @return string Title of this updater
74     */
75    public function getTitle(): string
76    {
77        return 'Execute database migrations on single rows';
78    }
79
80    /**
81     * @return string Longer description of this updater
82     * @throws \RuntimeException
83     */
84    public function getDescription(): string
85    {
86        $rowUpdaterNotExecuted = $this->getRowUpdatersToExecute();
87        $description = 'Row updaters that have not been executed:';
88        foreach ($rowUpdaterNotExecuted as $rowUpdateClassName) {
89            $rowUpdater = GeneralUtility::makeInstance($rowUpdateClassName);
90            if (!$rowUpdater instanceof RowUpdaterInterface) {
91                throw new \RuntimeException(
92                    'Row updater must implement RowUpdaterInterface',
93                    1484066647
94                );
95            }
96            $description .= LF . $rowUpdater->getTitle();
97        }
98        return $description;
99    }
100
101    /**
102     * @return bool True if at least one row updater is not marked done
103     */
104    public function updateNecessary(): bool
105    {
106        return !empty($this->getRowUpdatersToExecute());
107    }
108
109    /**
110     * @return string[] All new fields and tables must exist
111     */
112    public function getPrerequisites(): array
113    {
114        return [
115            DatabaseUpdatedPrerequisite::class
116        ];
117    }
118
119    /**
120     * Performs the configuration update.
121     *
122     * @return bool
123     * @throws \Doctrine\DBAL\ConnectionException
124     * @throws \Exception
125     */
126    public function executeUpdate(): bool
127    {
128        $registry = GeneralUtility::makeInstance(Registry::class);
129
130        // If rows from the target table that is updated and the sys_registry table are on the
131        // same connection, the row update statement and sys_registry position update will be
132        // handled in a transaction to have an atomic operation in case of errors during execution.
133        $connectionPool = GeneralUtility::makeInstance(ConnectionPool::class);
134        $connectionForSysRegistry = $connectionPool->getConnectionForTable('sys_registry');
135
136        /** @var RowUpdaterInterface[] $rowUpdaterInstances */
137        $rowUpdaterInstances = [];
138        // Single row updater instances are created only once for this method giving
139        // them a chance to set up local properties during hasPotentialUpdateForTable()
140        // and using that in updateTableRow()
141        foreach ($this->getRowUpdatersToExecute() as $rowUpdater) {
142            $rowUpdaterInstance = GeneralUtility::makeInstance($rowUpdater);
143            if (!$rowUpdaterInstance instanceof RowUpdaterInterface) {
144                throw new \RuntimeException(
145                    'Row updater must implement RowUpdaterInterface',
146                    1484071612
147                );
148            }
149            $rowUpdaterInstances[] = $rowUpdaterInstance;
150        }
151
152        // Scope of the row updater is to update all rows that have TCA,
153        // our list of tables is just the list of loaded TCA tables.
154        /** @var string[] $listOfAllTables */
155        $listOfAllTables = array_keys($GLOBALS['TCA']);
156
157        // In case the PHP ended for whatever reason, fetch the last position from registry
158        // and throw away all tables before that start point.
159        sort($listOfAllTables);
160        reset($listOfAllTables);
161        $firstTable = current($listOfAllTables);
162        $startPosition = $this->getStartPosition($firstTable);
163        foreach ($listOfAllTables as $key => $table) {
164            if ($table === $startPosition['table']) {
165                break;
166            }
167            unset($listOfAllTables[$key]);
168        }
169
170        // Ask each row updater if it potentially has field updates for rows of a table
171        $tableToUpdaterList = [];
172        foreach ($listOfAllTables as $table) {
173            foreach ($rowUpdaterInstances as $updater) {
174                if ($updater->hasPotentialUpdateForTable($table)) {
175                    if (!is_array($tableToUpdaterList[$table])) {
176                        $tableToUpdaterList[$table] = [];
177                    }
178                    $tableToUpdaterList[$table][] = $updater;
179                }
180            }
181        }
182
183        // Iterate through all rows of all tables that have potential row updaters attached,
184        // feed each single row to each updater and finally update each row in database if
185        // a row updater changed a fields
186        foreach ($tableToUpdaterList as $table => $updaters) {
187            /** @var RowUpdaterInterface[] $updaters */
188            $connectionForTable = $connectionPool->getConnectionForTable($table);
189            $queryBuilder = $connectionPool->getQueryBuilderForTable($table);
190            $queryBuilder->getRestrictions()->removeAll();
191            $queryBuilder->select('*')
192                ->from($table)
193                ->orderBy('uid');
194            if ($table === $startPosition['table']) {
195                $queryBuilder->where(
196                    $queryBuilder->expr()->gt('uid', $queryBuilder->createNamedParameter($startPosition['uid']))
197                );
198            }
199            $statement = $queryBuilder->execute();
200            $rowCountWithoutUpdate = 0;
201            while ($row = $rowBefore = $statement->fetch()) {
202                foreach ($updaters as $updater) {
203                    $row = $updater->updateTableRow($table, $row);
204                }
205                $updatedFields = array_diff_assoc($row, $rowBefore);
206                if (empty($updatedFields)) {
207                    // Updaters changed no field of that row
208                    $rowCountWithoutUpdate++;
209                    if ($rowCountWithoutUpdate >= 200) {
210                        // Update startPosition if there were many rows without data change
211                        $startPosition = [
212                            'table' => $table,
213                            'uid' => $row['uid'],
214                        ];
215                        $registry->set('installUpdateRows', 'rowUpdatePosition', $startPosition);
216                        $rowCountWithoutUpdate = 0;
217                    }
218                } else {
219                    $rowCountWithoutUpdate = 0;
220                    $startPosition = [
221                        'table' => $table,
222                        'uid' => $rowBefore['uid'],
223                    ];
224                    if ($connectionForSysRegistry === $connectionForTable
225                        && !($connectionForSysRegistry->getDatabasePlatform() instanceof SQLServerPlatform)
226                    ) {
227                        // Target table and sys_registry table are on the same connection and not mssql, use a transaction
228                        $connectionForTable->beginTransaction();
229                        try {
230                            $this->updateOrDeleteRow(
231                                $connectionForTable,
232                                $connectionForTable,
233                                $table,
234                                (int)$rowBefore['uid'],
235                                $updatedFields,
236                                $startPosition
237                            );
238                            $connectionForTable->commit();
239                        } catch (\Exception $up) {
240                            $connectionForTable->rollBack();
241                            throw $up;
242                        }
243                    } else {
244                        // Either different connections for table and sys_registry, or mssql.
245                        // SqlServer can not run a transaction for a table if the same table is queried
246                        // currently - our above ->fetch() main loop.
247                        // So, execute two distinct queries and hope for the best.
248                        $this->updateOrDeleteRow(
249                            $connectionForTable,
250                            $connectionForSysRegistry,
251                            $table,
252                            (int)$rowBefore['uid'],
253                            $updatedFields,
254                            $startPosition
255                        );
256                    }
257                }
258            }
259        }
260
261        // Ready with updates, remove position information from sys_registry
262        $registry->remove('installUpdateRows', 'rowUpdatePosition');
263        // Mark row updaters that were executed as done
264        foreach ($rowUpdaterInstances as $updater) {
265            $this->setRowUpdaterExecuted($updater);
266        }
267
268        return true;
269    }
270
271    /**
272     * Return an array of class names that are not yet marked as done.
273     *
274     * @return array Class names
275     */
276    protected function getRowUpdatersToExecute(): array
277    {
278        $doneRowUpdater = GeneralUtility::makeInstance(Registry::class)->get('installUpdateRows', 'rowUpdatersDone', []);
279        return array_diff($this->rowUpdater, $doneRowUpdater);
280    }
281
282    /**
283     * Mark a single updater as done
284     *
285     * @param RowUpdaterInterface $updater
286     */
287    protected function setRowUpdaterExecuted(RowUpdaterInterface $updater)
288    {
289        $registry = GeneralUtility::makeInstance(Registry::class);
290        $doneRowUpdater = $registry->get('installUpdateRows', 'rowUpdatersDone', []);
291        $doneRowUpdater[] = get_class($updater);
292        $registry->set('installUpdateRows', 'rowUpdatersDone', $doneRowUpdater);
293    }
294
295    /**
296     * Return an array with table / uid combination that specifies the start position the
297     * update row process should start with.
298     *
299     * @param string $firstTable Table name of the first TCA in case the start position needs to be initialized
300     * @return array New start position
301     */
302    protected function getStartPosition(string $firstTable): array
303    {
304        $registry = GeneralUtility::makeInstance(Registry::class);
305        $startPosition = $registry->get('installUpdateRows', 'rowUpdatePosition', []);
306        if (empty($startPosition)) {
307            $startPosition = [
308                'table' => $firstTable,
309                'uid' => 0,
310            ];
311            $registry->set('installUpdateRows', 'rowUpdatePosition', $startPosition);
312        }
313        return $startPosition;
314    }
315
316    /**
317     * @param Connection $connectionForTable
318     * @param string $table
319     * @param array $updatedFields
320     * @param int $uid
321     * @param Connection $connectionForSysRegistry
322     * @param array $startPosition
323     */
324    protected function updateOrDeleteRow(Connection $connectionForTable, Connection $connectionForSysRegistry, string $table, int $uid, array $updatedFields, array $startPosition): void
325    {
326        $deleteField = $GLOBALS['TCA'][$table]['ctrl']['delete'] ?? null;
327        if ($deleteField === null && $updatedFields['deleted'] === 1) {
328            $connectionForTable->delete(
329                $table,
330                [
331                    'uid' => $uid,
332                ]
333            );
334        } else {
335            $connectionForTable->update(
336                $table,
337                $updatedFields,
338                [
339                    'uid' => $uid,
340                ]
341            );
342        }
343        $connectionForSysRegistry->update(
344            'sys_registry',
345            [
346                'entry_value' => serialize($startPosition),
347            ],
348            [
349                'entry_namespace' => 'installUpdateRows',
350                'entry_key' => 'rowUpdatePosition',
351            ],
352            [
353                // Needs to be declared LOB, so MSSQL can handle the conversion from string (nvarchar) to blob (varbinary)
354                'entry_value' => \PDO::PARAM_LOB,
355                'entry_namespace' => \PDO::PARAM_STR,
356                'entry_key' => \PDO::PARAM_STR,
357            ]
358        );
359    }
360}
361