[FOLLOWUP][FEATURE] Install tool table row updaters
[Packages/TYPO3.CMS.git] / typo3 / sysext / install / Classes / Updates / DatabaseRowsUpdateWizard.php
1 <?php
2 declare(strict_types=1);
3 namespace TYPO3\CMS\Install\Updates;
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 use TYPO3\CMS\Core\Database\ConnectionPool;
18 use TYPO3\CMS\Core\Registry;
19 use TYPO3\CMS\Core\Utility\GeneralUtility;
20 use TYPO3\CMS\Install\Updates\RowUpdater\RowUpdaterInterface;
21
22 /**
23 * This is a generic updater to migrate content of TCA rows.
24 *
25 * Multiple classes implementing interface "RowUpdateInterface" can be
26 * registered here, each for a specific update purpose.
27 *
28 * The updater fetches each row of all TCA registered tables and
29 * visits the client classes who may modify the row content.
30 *
31 * The updater remembers for each class if it run through, so the updater
32 * will be shown again if a new updater class is registered that has not
33 * been run yet.
34 *
35 * A start position pointer is stored in the registry that is updated during
36 * the run process, so if for instance the PHP process runs into a timeout,
37 * the job can restart at the position it stopped.
38 */
39 class DatabaseRowsUpdateWizard extends AbstractUpdate
40 {
41 /**
42 * @var string Title of this updater
43 */
44 protected $title = 'Execute database migrations on single rows';
45
46 /**
47 * @var array Single classes that may update rows
48 */
49 protected $rowUpdater = [
50 ];
51
52 /**
53 * Checks if an update is needed by looking up in registry if all
54 * registered update row classes are marked as done or not.
55 *
56 * @param string &$description The description for the update
57 * @return bool Whether an update is needed (TRUE) or not (FALSE)
58 */
59 public function checkForUpdate(&$description)
60 {
61 $updateNeeded = false;
62 $rowUpdaterNotExecuted = $this->getRowUpdatersToExecute();
63 if (!empty($rowUpdaterNotExecuted)) {
64 $updateNeeded = true;
65 }
66 if (!$updateNeeded) {
67 return false;
68 }
69
70 $description = 'Some row updaters have not been executed:';
71 foreach ($rowUpdaterNotExecuted as $rowUpdateClassName) {
72 $rowUpdater = GeneralUtility::makeInstance($rowUpdateClassName);
73 if (!$rowUpdater instanceof RowUpdaterInterface) {
74 throw new \RuntimeException(
75 'Row updater must implement RowUpdaterInterface',
76 1484066647
77 );
78 }
79 $description .= '<br />' . htmlspecialchars($rowUpdater->getTitle());
80 }
81
82 return $updateNeeded;
83 }
84
85 /**
86 * Performs the configuration update.
87 *
88 * @param array &$databaseQueries Queries done in this update - not filled for this updater
89 * @param mixed &$customMessages Custom messages
90 * @return bool
91 * @throws \Doctrine\DBAL\ConnectionException
92 * @throws \Exception
93 */
94 public function performUpdate(array &$databaseQueries, &$customMessages)
95 {
96 $registry = GeneralUtility::makeInstance(Registry::class);
97
98 // If rows from the target table that is updated and the sys_registry table are on the
99 // same connection, the row update statement and sys_registry position update will be
100 // handled in a transaction to have an atomic operation in case of errors during execution.
101 $connectionPool = GeneralUtility::makeInstance(ConnectionPool::class);
102 $connectionForSysRegistry = $connectionPool->getConnectionForTable('sys_registry');
103
104 /** @var RowUpdaterInterface[] $rowUpdaterInstances */
105 $rowUpdaterInstances = [];
106 // Single row updater instances are created only once for this method giving
107 // them a chance to set up local properties during hasPotentialUpdateForTable()
108 // and using that in updateTableRow()
109 foreach ($this->getRowUpdatersToExecute() as $rowUpdater) {
110 $rowUpdaterInstance = GeneralUtility::makeInstance($rowUpdater);
111 if (!$rowUpdaterInstance instanceof RowUpdaterInterface) {
112 throw new \RuntimeException(
113 'Row updater must implement RowUpdaterInterface',
114 1484071612
115 );
116 }
117 $rowUpdaterInstances[] = $rowUpdaterInstance;
118 }
119
120 // Scope of the row updater is to update all rows that have TCA,
121 // our list of tables is just the list of loaded TCA tables.
122 $listOfAllTables = array_keys($GLOBALS['TCA']);
123
124 // In case the PHP ended for whatever reason, fetch the last position from registry
125 // and throw away all tables before that start point.
126 sort($listOfAllTables);
127 reset($listOfAllTables);
128 $firstTable = current($listOfAllTables);
129 $startPosition = $this->getStartPosition($firstTable);
130 foreach ($listOfAllTables as $table) {
131 if ($table === $startPosition['table']) {
132 break;
133 } else {
134 unset($listOfAllTables[$table]);
135 }
136 }
137
138 // Ask each row updater if it potentially has field updates for rows of a table
139 $tableToUpdaterList = [];
140 foreach ($listOfAllTables as $table) {
141 foreach ($rowUpdaterInstances as $updater) {
142 if ($updater->hasPotentialUpdateForTable($table)) {
143 if (!is_array($tableToUpdaterList[$table])) {
144 $tableToUpdaterList[$table] = [];
145 }
146 $tableToUpdaterList[$table][] = $updater;
147 }
148 }
149 }
150
151 // Iterate through all rows of all tables that have potential row updaters attached,
152 // feed each single row to each updater and finally update each row in database if
153 // a row updater changed a fields
154 foreach ($tableToUpdaterList as $table => $updaters) {
155 /** @var RowUpdaterInterface[] $updaters */
156 $connectionForTable = $connectionPool->getConnectionForTable($table);
157 $queryBuilder = $connectionPool->getQueryBuilderForTable($table);
158 $queryBuilder->getRestrictions()->removeAll();
159 $queryBuilder->select('*')
160 ->from($table)
161 ->orderBy('uid');
162 if ($table === $startPosition['table']) {
163 $queryBuilder->where(
164 $queryBuilder->expr()->gt('uid', $queryBuilder->createNamedParameter($startPosition['uid']))
165 );
166 }
167 $statement = $queryBuilder->execute();
168 $rowCountWithoutUpdate = 0;
169 while ($row = $rowBefore = $statement->fetch()) {
170 foreach ($updaters as $updater) {
171 $row = $updater->updateTableRow($table, $row);
172 }
173 $updatedFields = array_diff_assoc($row, $rowBefore);
174 if (empty($updatedFields)) {
175 // Updaters changed no field of that row
176 $rowCountWithoutUpdate ++;
177 if ($rowCountWithoutUpdate >= 200) {
178 // Update startPosition if there were many rows without data change
179 $startPosition = [
180 'table' => $table,
181 'uid' => $row['uid'],
182 ];
183 $registry->set('installUpdateRows', 'rowUpdatePosition', $startPosition);
184 $rowCountWithoutUpdate = 0;
185 }
186 } else {
187 $rowCountWithoutUpdate = 0;
188 $startPosition = [
189 'table' => $table,
190 'uid' => $rowBefore['uid'],
191 ];
192 if ($connectionForSysRegistry === $connectionForTable) {
193 // Target table and sys_registry table are on the same connection, use a transaction
194 $connectionForTable->beginTransaction();
195 try {
196 $connectionForTable->update(
197 $table,
198 $updatedFields,
199 [
200 'uid' => $rowBefore['uid'],
201 ]
202 );
203 $connectionForTable->update(
204 'sys_registry',
205 [
206 'entry_value' => serialize($startPosition),
207 ],
208 [
209 'entry_namespace' => 'installUpdateRows',
210 'entry_key' => 'rowUpdatePosition',
211 ]
212 );
213 $connectionForTable->commit();
214 } catch (\Exception $up) {
215 $connectionForTable->rollBack();
216 throw $up;
217 }
218 } else {
219 // Different connections for table and sys_registry -> execute two
220 // distinct queries and hope for the best.
221 $connectionForTable->update(
222 $table,
223 $updatedFields,
224 [
225 'uid' => $rowBefore['uid'],
226 ]
227 );
228 $connectionForSysRegistry->update(
229 'sys_registry',
230 [
231 'entry_value' => serialize($startPosition),
232 ],
233 [
234 'entry_namespace' => 'installUpdateRows',
235 'entry_key' => 'rowUpdatePosition',
236 ]
237 );
238 }
239 }
240 }
241 }
242
243 // Ready with updates, remove position information from sys_registry
244 $registry->remove('installUpdateRows', 'rowUpdatePosition');
245 // Mark row updaters that were executed as done
246 foreach ($rowUpdaterInstances as $updater) {
247 $this->setRowUpdaterExecuted($updater);
248 }
249
250 return true;
251 }
252
253 /**
254 * Return an array of class names that are not yet marked as done.
255 *
256 * @return array Class names
257 */
258 protected function getRowUpdatersToExecute(): array
259 {
260 $doneRowUpdater = GeneralUtility::makeInstance(Registry::class)->get('installUpdateRows', 'rowUpdatersDone', []);
261 return array_diff($this->rowUpdater, $doneRowUpdater);
262 }
263
264 /**
265 * Mark a single updater as done
266 *
267 * @param RowUpdaterInterface $updater
268 */
269 protected function setRowUpdaterExecuted(RowUpdaterInterface $updater)
270 {
271 $registry = GeneralUtility::makeInstance(Registry::class);
272 $doneRowUpdater = $registry->get('installUpdateRows', 'rowUpdatersDone', []);
273 $doneRowUpdater[] = get_class($updater);
274 $registry->set('installUpdateRows', 'rowUpdatersDone', $doneRowUpdater);
275 }
276
277 /**
278 * Return an array with table / uid combination that specifies the start position the
279 * update row process should start with.
280 *
281 * @param string $firstTable Table name of the first TCA in case the start position needs to be initialized
282 * @return array New start position
283 */
284 protected function getStartPosition(string $firstTable): array
285 {
286 $registry = GeneralUtility::makeInstance(Registry::class);
287 $startPosition = $registry->get('installUpdateRows', 'rowUpdaterPosition', []);
288 if (empty($startPosition)) {
289 $startPosition = [
290 'table' => $firstTable,
291 'uid' => 0,
292 ];
293 $registry->set('installUpdateRows', 'rowUpdatePosition', $startPosition);
294 }
295 return $startPosition;
296 }
297 }