DatabaseRowsUpdateWizard.php 14 KB
Newer Older
1
<?php
2

3
declare(strict_types=1);
4
5
6
7
8
9
10
11
12
13
14
15
16

/*
 * 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!
 */
17

18
19
namespace TYPO3\CMS\Install\Updates;

20
use Doctrine\DBAL\Platforms\SQLServerPlatform;
21
use TYPO3\CMS\Core\Database\Connection;
22
23
24
use TYPO3\CMS\Core\Database\ConnectionPool;
use TYPO3\CMS\Core\Registry;
use TYPO3\CMS\Core\Utility\GeneralUtility;
25
use TYPO3\CMS\Install\Updates\RowUpdater\L18nDiffsourceToJsonMigration;
26
use TYPO3\CMS\Install\Updates\RowUpdater\RowUpdaterInterface;
27
use TYPO3\CMS\Install\Updates\RowUpdater\WorkspaceMovePlaceholderRemovalMigration;
28
use TYPO3\CMS\Install\Updates\RowUpdater\WorkspaceNewPlaceholderRemovalMigration;
29
use TYPO3\CMS\Install\Updates\RowUpdater\WorkspaceVersionRecordsMigration;
30
31
32
33
34

/**
 * This is a generic updater to migrate content of TCA rows.
 *
 * Multiple classes implementing interface "RowUpdateInterface" can be
35
 * registered here, each for a specific update purpose.
36
37
38
39
40
41
42
43
44
45
 *
 * The updater fetches each row of all TCA registered tables and
 * visits the client classes who may modify the row content.
 *
 * The updater remembers for each class if it run through, so the updater
 * will be shown again if a new updater class is registered that has not
 * been run yet.
 *
 * A start position pointer is stored in the registry that is updated during
 * the run process, so if for instance the PHP process runs into a timeout,
46
 * the job can restart at the position it stopped.
47
 * @internal This class is only meant to be used within EXT:install and is not part of the TYPO3 Core API.
48
 */
49
class DatabaseRowsUpdateWizard implements UpgradeWizardInterface, RepeatableInterface
50
51
52
53
54
{
    /**
     * @var array Single classes that may update rows
     */
    protected $rowUpdater = [
55
        WorkspaceVersionRecordsMigration::class,
56
        L18nDiffsourceToJsonMigration::class,
57
        WorkspaceMovePlaceholderRemovalMigration::class,
58
        WorkspaceNewPlaceholderRemovalMigration::class,
59
60
    ];

61
62
63
64
65
66
67
68
69
    /**
     * @internal
     * @return string[]
     */
    public function getAvailableRowUpdater(): array
    {
        return $this->rowUpdater;
    }

70
    /**
71
     * @return string Unique identifier of this updater
72
     */
73
    public function getIdentifier(): string
74
    {
75
76
77
78
79
80
81
82
83
84
        return 'databaseRowsUpdateWizard';
    }

    /**
     * @return string Title of this updater
     */
    public function getTitle(): string
    {
        return 'Execute database migrations on single rows';
    }
85

86
87
    /**
     * @return string Longer description of this updater
88
     * @throws \RuntimeException
89
90
91
92
93
     */
    public function getDescription(): string
    {
        $rowUpdaterNotExecuted = $this->getRowUpdatersToExecute();
        $description = 'Row updaters that have not been executed:';
94
95
96
97
98
99
100
101
        foreach ($rowUpdaterNotExecuted as $rowUpdateClassName) {
            $rowUpdater = GeneralUtility::makeInstance($rowUpdateClassName);
            if (!$rowUpdater instanceof RowUpdaterInterface) {
                throw new \RuntimeException(
                    'Row updater must implement RowUpdaterInterface',
                    1484066647
                );
            }
102
            $description .= LF . $rowUpdater->getTitle();
103
        }
104
105
        return $description;
    }
106

107
108
109
110
111
112
113
114
115
116
117
118
119
120
121
122
    /**
     * @return bool True if at least one row updater is not marked done
     */
    public function updateNecessary(): bool
    {
        return !empty($this->getRowUpdatersToExecute());
    }

    /**
     * @return string[] All new fields and tables must exist
     */
    public function getPrerequisites(): array
    {
        return [
            DatabaseUpdatedPrerequisite::class
        ];
123
124
125
126
127
128
129
130
131
    }

    /**
     * Performs the configuration update.
     *
     * @return bool
     * @throws \Doctrine\DBAL\ConnectionException
     * @throws \Exception
     */
132
    public function executeUpdate(): bool
133
134
135
136
137
138
139
140
141
142
143
144
145
146
147
148
149
150
151
152
153
154
155
156
157
158
    {
        $registry = GeneralUtility::makeInstance(Registry::class);

        // If rows from the target table that is updated and the sys_registry table are on the
        // same connection, the row update statement and sys_registry position update will be
        // handled in a transaction to have an atomic operation in case of errors during execution.
        $connectionPool = GeneralUtility::makeInstance(ConnectionPool::class);
        $connectionForSysRegistry = $connectionPool->getConnectionForTable('sys_registry');

        /** @var RowUpdaterInterface[] $rowUpdaterInstances */
        $rowUpdaterInstances = [];
        // Single row updater instances are created only once for this method giving
        // them a chance to set up local properties during hasPotentialUpdateForTable()
        // and using that in updateTableRow()
        foreach ($this->getRowUpdatersToExecute() as $rowUpdater) {
            $rowUpdaterInstance = GeneralUtility::makeInstance($rowUpdater);
            if (!$rowUpdaterInstance instanceof RowUpdaterInterface) {
                throw new \RuntimeException(
                    'Row updater must implement RowUpdaterInterface',
                    1484071612
                );
            }
            $rowUpdaterInstances[] = $rowUpdaterInstance;
        }

        // Scope of the row updater is to update all rows that have TCA,
159
        // our list of tables is just the list of loaded TCA tables.
160
        /** @var string[] $listOfAllTables */
161
162
163
        $listOfAllTables = array_keys($GLOBALS['TCA']);

        // In case the PHP ended for whatever reason, fetch the last position from registry
164
        // and throw away all tables before that start point.
165
166
167
168
        sort($listOfAllTables);
        reset($listOfAllTables);
        $firstTable = current($listOfAllTables);
        $startPosition = $this->getStartPosition($firstTable);
169
        foreach ($listOfAllTables as $key => $table) {
170
171
172
            if ($table === $startPosition['table']) {
                break;
            }
173
            unset($listOfAllTables[$key]);
174
175
        }

176
        // Ask each row updater if it potentially has field updates for rows of a table
177
178
179
180
181
182
183
184
185
186
187
188
        $tableToUpdaterList = [];
        foreach ($listOfAllTables as $table) {
            foreach ($rowUpdaterInstances as $updater) {
                if ($updater->hasPotentialUpdateForTable($table)) {
                    if (!is_array($tableToUpdaterList[$table])) {
                        $tableToUpdaterList[$table] = [];
                    }
                    $tableToUpdaterList[$table][] = $updater;
                }
            }
        }

189
190
191
        // Iterate through all rows of all tables that have potential row updaters attached,
        // feed each single row to each updater and finally update each row in database if
        // a row updater changed a fields
192
193
194
195
196
197
198
199
200
201
202
203
204
205
206
207
208
209
210
211
212
213
        foreach ($tableToUpdaterList as $table => $updaters) {
            /** @var RowUpdaterInterface[] $updaters */
            $connectionForTable = $connectionPool->getConnectionForTable($table);
            $queryBuilder = $connectionPool->getQueryBuilderForTable($table);
            $queryBuilder->getRestrictions()->removeAll();
            $queryBuilder->select('*')
                ->from($table)
                ->orderBy('uid');
            if ($table === $startPosition['table']) {
                $queryBuilder->where(
                    $queryBuilder->expr()->gt('uid', $queryBuilder->createNamedParameter($startPosition['uid']))
                );
            }
            $statement = $queryBuilder->execute();
            $rowCountWithoutUpdate = 0;
            while ($row = $rowBefore = $statement->fetch()) {
                foreach ($updaters as $updater) {
                    $row = $updater->updateTableRow($table, $row);
                }
                $updatedFields = array_diff_assoc($row, $rowBefore);
                if (empty($updatedFields)) {
                    // Updaters changed no field of that row
Christian Kuhn's avatar
Christian Kuhn committed
214
                    $rowCountWithoutUpdate++;
215
                    if ($rowCountWithoutUpdate >= 200) {
216
                        // Update startPosition if there were many rows without data change
217
218
219
220
221
222
223
224
225
226
227
228
229
                        $startPosition = [
                            'table' => $table,
                            'uid' => $row['uid'],
                        ];
                        $registry->set('installUpdateRows', 'rowUpdatePosition', $startPosition);
                        $rowCountWithoutUpdate = 0;
                    }
                } else {
                    $rowCountWithoutUpdate = 0;
                    $startPosition = [
                        'table' => $table,
                        'uid' => $rowBefore['uid'],
                    ];
230
231
232
233
                    if ($connectionForSysRegistry === $connectionForTable
                        && !($connectionForSysRegistry->getDatabasePlatform() instanceof SQLServerPlatform)
                    ) {
                        // Target table and sys_registry table are on the same connection and not mssql, use a transaction
234
235
                        $connectionForTable->beginTransaction();
                        try {
236
237
238
                            $this->updateOrDeleteRow(
                                $connectionForTable,
                                $connectionForTable,
239
                                $table,
240
                                (int)$rowBefore['uid'],
241
                                $updatedFields,
242
                                $startPosition
243
244
245
246
247
248
249
                            );
                            $connectionForTable->commit();
                        } catch (\Exception $up) {
                            $connectionForTable->rollBack();
                            throw $up;
                        }
                    } else {
250
251
252
253
                        // Either different connections for table and sys_registry, or mssql.
                        // SqlServer can not run a transaction for a table if the same table is queried
                        // currently - our above ->fetch() main loop.
                        // So, execute two distinct queries and hope for the best.
254
255
256
                        $this->updateOrDeleteRow(
                            $connectionForTable,
                            $connectionForSysRegistry,
257
                            $table,
258
                            (int)$rowBefore['uid'],
259
                            $updatedFields,
260
                            $startPosition
261
262
263
264
265
266
267
268
                        );
                    }
                }
            }
        }

        // Ready with updates, remove position information from sys_registry
        $registry->remove('installUpdateRows', 'rowUpdatePosition');
269
        // Mark row updaters that were executed as done
270
271
272
273
274
275
276
277
278
279
280
281
282
283
284
285
286
287
288
289
290
291
292
293
294
295
296
297
298
299
300
301
302
303
304
305
306
307
308
309
310
        foreach ($rowUpdaterInstances as $updater) {
            $this->setRowUpdaterExecuted($updater);
        }

        return true;
    }

    /**
     * Return an array of class names that are not yet marked as done.
     *
     * @return array Class names
     */
    protected function getRowUpdatersToExecute(): array
    {
        $doneRowUpdater = GeneralUtility::makeInstance(Registry::class)->get('installUpdateRows', 'rowUpdatersDone', []);
        return array_diff($this->rowUpdater, $doneRowUpdater);
    }

    /**
     * Mark a single updater as done
     *
     * @param RowUpdaterInterface $updater
     */
    protected function setRowUpdaterExecuted(RowUpdaterInterface $updater)
    {
        $registry = GeneralUtility::makeInstance(Registry::class);
        $doneRowUpdater = $registry->get('installUpdateRows', 'rowUpdatersDone', []);
        $doneRowUpdater[] = get_class($updater);
        $registry->set('installUpdateRows', 'rowUpdatersDone', $doneRowUpdater);
    }

    /**
     * Return an array with table / uid combination that specifies the start position the
     * update row process should start with.
     *
     * @param string $firstTable Table name of the first TCA in case the start position needs to be initialized
     * @return array New start position
     */
    protected function getStartPosition(string $firstTable): array
    {
        $registry = GeneralUtility::makeInstance(Registry::class);
311
        $startPosition = $registry->get('installUpdateRows', 'rowUpdatePosition', []);
312
313
314
315
316
317
318
319
320
        if (empty($startPosition)) {
            $startPosition = [
                'table' => $firstTable,
                'uid' => 0,
            ];
            $registry->set('installUpdateRows', 'rowUpdatePosition', $startPosition);
        }
        return $startPosition;
    }
321
322
323
324
325
326
327
328
329
330
331
332
333
334
335
336
337
338
339
340
341
342
343
344
345
346
347
348
349
350
351
352
353
354
355
356

    /**
     * @param Connection $connectionForTable
     * @param string $table
     * @param array $updatedFields
     * @param int $uid
     * @param Connection $connectionForSysRegistry
     * @param array $startPosition
     */
    protected function updateOrDeleteRow(Connection $connectionForTable, Connection $connectionForSysRegistry, string $table, int $uid, array $updatedFields, array $startPosition): void
    {
        $deleteField = $GLOBALS['TCA'][$table]['ctrl']['delete'] ?? null;
        if ($deleteField === null && $updatedFields['deleted'] === 1) {
            $connectionForTable->delete(
                $table,
                [
                    'uid' => $uid,
                ]
            );
        } else {
            $connectionForTable->update(
                $table,
                $updatedFields,
                [
                    'uid' => $uid,
                ]
            );
        }
        $connectionForSysRegistry->update(
            'sys_registry',
            [
                'entry_value' => serialize($startPosition),
            ],
            [
                'entry_namespace' => 'installUpdateRows',
                'entry_key' => 'rowUpdatePosition',
357
358
359
360
361
362
            ],
            [
                // Needs to be declared LOB, so MSSQL can handle the conversion from string (nvarchar) to blob (varbinary)
                'entry_value' => \PDO::PARAM_LOB,
                'entry_namespace' => \PDO::PARAM_STR,
                'entry_key' => \PDO::PARAM_STR,
363
364
365
            ]
        );
    }
366
}