[TASK] Fix spelling issues
[Packages/TYPO3.CMS.git] / typo3 / sysext / core / Classes / Database / Schema / SchemaMigrator.php
1 <?php
2 declare(strict_types=1);
3 namespace TYPO3\CMS\Core\Database\Schema;
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
18 use Doctrine\DBAL\DBALException;
19 use Doctrine\DBAL\Schema\Column;
20 use Doctrine\DBAL\Schema\ColumnDiff;
21 use Doctrine\DBAL\Schema\Comparator;
22 use Doctrine\DBAL\Schema\Schema;
23 use Doctrine\DBAL\Schema\SchemaConfig;
24 use Doctrine\DBAL\Schema\SchemaDiff;
25 use Doctrine\DBAL\Schema\Table;
26 use Doctrine\DBAL\Schema\TableDiff;
27 use TYPO3\CMS\Core\Database\Connection;
28 use TYPO3\CMS\Core\Database\ConnectionPool;
29 use TYPO3\CMS\Core\Database\Schema\Parser\Parser;
30 use TYPO3\CMS\Core\Utility\GeneralUtility;
31 use TYPO3\CMS\Core\Utility\StringUtility;
32
33 /**
34 * Helper methods to handle SQL files and transform them into individual statements
35 * for further processing.
36 *
37 * @internal
38 */
39 class SchemaMigrator
40 {
41 /**
42 * @var string Prefix of deleted tables
43 */
44 protected $deletedPrefix = 'zzz_deleted_';
45
46 /**
47 * @var Table[]
48 */
49 protected $tables = [];
50
51 /**
52 * @var Schema[]
53 */
54 protected $schema = [];
55
56 /**
57 * Compare current and expected schema definitions and provide updates suggestions in the form
58 * of SQL statements.
59 *
60 * @param string[] $statements The CREATE TABLE statements
61 * @param bool $remove TRUE for RENAME/DROP table and column suggestions, FALSE for ADD/CHANGE suggestions
62 * @return array[] SQL statements to migrate the database to the expected schema, indexed by performed operation
63 * @throws \Doctrine\DBAL\DBALException
64 * @throws \Doctrine\DBAL\Schema\SchemaException
65 * @throws \InvalidArgumentException
66 * @throws \RuntimeException
67 * @throws \TYPO3\CMS\Core\Database\Schema\Exception\UnexpectedSignalReturnValueTypeException
68 * @throws \TYPO3\CMS\Extbase\SignalSlot\Exception\InvalidSlotException
69 * @throws \TYPO3\CMS\Extbase\SignalSlot\Exception\InvalidSlotReturnException
70 * @throws \TYPO3\CMS\Core\Database\Schema\Exception\StatementException
71 */
72 public function getUpdateSuggestions(array $statements, bool $remove = false): array
73 {
74 $connectionPool = GeneralUtility::makeInstance(ConnectionPool::class);
75 $this->parseCreateTableStatements($statements);
76
77 $updateSuggestions = [];
78
79 foreach ($connectionPool->getConnectionNames() as $connectionName) {
80 $connection = $connectionPool->getConnectionByName($connectionName);
81
82 $schemaDiff = $this->buildSchemaDiff($connectionName);
83
84 if ($remove === false) {
85 $updateSuggestions[$connectionName] = array_merge_recursive(
86 ['add' => [], 'create_table' => [], 'change' => [], 'change_currentValue' => []],
87 $this->getNewFieldUpdateSuggestions($schemaDiff, $connection),
88 $this->getNewTableUpdateSuggestions($schemaDiff, $connection),
89 $this->getChangedFieldUpdateSuggestions($schemaDiff, $connection)
90 );
91 } else {
92 $updateSuggestions[$connectionName] = array_merge_recursive(
93 ['change' => [], 'change_table' => [], 'drop' => [], 'drop_table' => [], 'tables_count' => []],
94 $this->getUnusedFieldUpdateSuggestions($schemaDiff, $connection),
95 $this->getUnusedTableUpdateSuggestions($schemaDiff, $connection),
96 $this->getDropTableUpdateSuggestions($schemaDiff, $connection),
97 $this->getDropFieldUpdateSuggestions($schemaDiff, $connection)
98 );
99 }
100 }
101
102 return $updateSuggestions;
103 }
104
105 /**
106 * Return the raw Doctrine SchemaDiff objects for each connection. This diff contains
107 * all changes without any pre-processing.
108 *
109 * @param array $statements
110 * @return SchemaDiff[]
111 * @throws \Doctrine\DBAL\DBALException
112 * @throws \Doctrine\DBAL\Schema\SchemaException
113 * @throws \InvalidArgumentException
114 * @throws \RuntimeException
115 * @throws \TYPO3\CMS\Core\Database\Schema\Exception\UnexpectedSignalReturnValueTypeException
116 * @throws \TYPO3\CMS\Extbase\SignalSlot\Exception\InvalidSlotException
117 * @throws \TYPO3\CMS\Extbase\SignalSlot\Exception\InvalidSlotReturnException
118 * @throws \TYPO3\CMS\Core\Database\Schema\Exception\StatementException
119 */
120 public function getSchemaDiffs(array $statements): array
121 {
122 $connectionPool = GeneralUtility::makeInstance(ConnectionPool::class);
123 $this->parseCreateTableStatements($statements);
124
125 $schemaDiffs = [];
126
127 foreach ($connectionPool->getConnectionNames() as $connectionName) {
128 $schemaDiffs[$connectionName] = $this->buildSchemaDiff($connectionName, false);
129 }
130
131 return $schemaDiffs;
132 }
133
134 /**
135 * This method executes statements from the update suggestions, or a subset of them
136 * filtered by the statements hashes, one by one.
137 *
138 * @param string[] $statements The CREATE TABLE statements
139 * @param string[] $selectedStatements The hashes of the update suggestions to execute
140 * @return array
141 * @throws \Doctrine\DBAL\DBALException
142 * @throws \Doctrine\DBAL\Schema\SchemaException
143 * @throws \InvalidArgumentException
144 * @throws \TYPO3\CMS\Extbase\SignalSlot\Exception\InvalidSlotReturnException
145 * @throws \TYPO3\CMS\Extbase\SignalSlot\Exception\InvalidSlotException
146 * @throws \TYPO3\CMS\Core\Database\Schema\Exception\UnexpectedSignalReturnValueTypeException
147 * @throws \TYPO3\CMS\Core\Database\Schema\Exception\StatementException
148 * @throws \RuntimeException
149 */
150 public function migrate(array $statements, array $selectedStatements): array
151 {
152 $result = [];
153 $connectionPool = GeneralUtility::makeInstance(ConnectionPool::class);
154 $updateSuggestionsPerConnection = array_merge_recursive(
155 $this->getUpdateSuggestions($statements),
156 $this->getUpdateSuggestions($statements, true)
157 );
158
159 foreach ($updateSuggestionsPerConnection as $connectionName => $updateSuggestions) {
160 unset($updateSuggestions['tables_count'], $updateSuggestions['change_currentValue']);
161 $updateSuggestions = array_merge(...array_values($updateSuggestions));
162 $statementsToExecute = array_intersect_key($updateSuggestions, $selectedStatements);
163 if (count($statementsToExecute) === 0) {
164 continue;
165 }
166
167 $connection = $connectionPool->getConnectionByName($connectionName);
168 foreach ($statementsToExecute as $hash => $statement) {
169 try {
170 $connection->executeUpdate($statement);
171 } catch (DBALException $e) {
172 $result[$hash] = $e->getPrevious()->getMessage();
173 }
174 }
175 }
176
177 return $result;
178 }
179
180 /**
181 * Perform add/change/create operations on tables and fields in an optimized,
182 * non-interactive, mode using the original doctrine SchemaManager ->toSaveSql()
183 * method.
184 *
185 * @param string[] $statements The CREATE TABLE statements
186 * @param bool $createOnly Only perform changes that add fields or create tables
187 * @return array[] Error messages for statements that occurred during the installation procedure.
188 * @throws \Doctrine\DBAL\DBALException
189 * @throws \Doctrine\DBAL\Schema\SchemaException
190 * @throws \InvalidArgumentException
191 * @throws \RuntimeException
192 * @throws \TYPO3\CMS\Core\Database\Schema\Exception\UnexpectedSignalReturnValueTypeException
193 * @throws \TYPO3\CMS\Extbase\SignalSlot\Exception\InvalidSlotException
194 * @throws \TYPO3\CMS\Extbase\SignalSlot\Exception\InvalidSlotReturnException
195 * @throws \TYPO3\CMS\Core\Database\Schema\Exception\StatementException
196 */
197 public function install(array $statements, bool $createOnly = false): array
198 {
199 $connectionPool = GeneralUtility::makeInstance(ConnectionPool::class);
200 $this->parseCreateTableStatements($statements);
201 $result = [];
202
203 foreach ($connectionPool->getConnectionNames() as $connectionName) {
204 $connection = $connectionPool->getConnectionByName($connectionName);
205
206 $schemaDiff = $this->buildSchemaDiff($connectionName, false);
207
208 $schemaDiff->removedTables = [];
209 foreach ($schemaDiff->changedTables as $key => $changedTable) {
210 $schemaDiff->changedTables[$key]->removedColumns = [];
211 $schemaDiff->changedTables[$key]->removedIndexes = [];
212 if ($createOnly) {
213 $schemaDiff->changedTables[$key]->changedColumns = [];
214 $schemaDiff->changedTables[$key]->renamedIndexes = [];
215 }
216 }
217
218 $statements = $schemaDiff->toSaveSql($connection->getDatabasePlatform());
219
220 foreach ($statements as $hash => $statement) {
221 try {
222 $connection->executeUpdate($statement);
223 $result[$statement] = '';
224 } catch (DBALException $e) {
225 $result[$statement] = $e->getPrevious()->getMessage();
226 }
227 }
228 }
229
230 return $result;
231 }
232
233 /**
234 * Import static data (INSERT statements)
235 *
236 * @param array $statements
237 * @param bool $truncate
238 * @return array
239 */
240 public function importStaticData(array $statements, bool $truncate = false): array
241 {
242 $result = [];
243 $connectionPool = GeneralUtility::makeInstance(ConnectionPool::class);
244 $insertStatements = [];
245
246 foreach ($statements as $statement) {
247 // Only handle insert statements and extract the table at the same time. Extracting
248 // the table name is required to perform the inserts on the right connection.
249 if (preg_match('/^INSERT\s+INTO\s+`?(\w+)`?(.*)/i', $statement, $matches)) {
250 list(, $tableName, $sqlFragment) = $matches;
251 $insertStatements[$tableName][] = sprintf(
252 'INSERT INTO %s %s',
253 $connectionPool->getConnectionForTable($tableName)->quoteIdentifier($tableName),
254 rtrim($sqlFragment, ';')
255 );
256 }
257 }
258
259 foreach ($insertStatements as $tableName => $perTableStatements) {
260 $connection = $connectionPool->getConnectionForTable($tableName);
261
262 if ($truncate) {
263 $connection->truncate($tableName);
264 }
265
266 foreach ((array)$perTableStatements as $statement) {
267 try {
268 $connection->executeUpdate($statement);
269 $result[$statement] = '';
270 } catch (DBALException $e) {
271 $result[$statement] = $e->getPrevious()->getMessage();
272 }
273 }
274 }
275
276 return $result;
277 }
278
279 /**
280 * If the schema is not for the Default connection remove all tables from the schema
281 * that have no mapping in the TYPO3 configuration. This avoids update suggestions
282 * for tables that are in the database but have no direct relation to the TYPO3 instance.
283 *
284 * @param string $connectionName
285 * @param bool $renameUnused
286 * @return \Doctrine\DBAL\Schema\SchemaDiff
287 * @throws \Doctrine\DBAL\DBALException
288 * @throws \InvalidArgumentException
289 */
290 protected function buildSchemaDiff(string $connectionName, bool $renameUnused = true): SchemaDiff
291 {
292 $connection = GeneralUtility::makeInstance(ConnectionPool::class)->getConnectionByName($connectionName);
293
294 // Build the schema definitions
295 $fromSchema = $connection->getSchemaManager()->createSchema();
296 $toSchema = $this->buildExpectedSchemaDefinitions($connectionName);
297
298 // Build SchemaDiff and handle renames of tables and colums
299 $comparator = GeneralUtility::makeInstance(Comparator::class);
300 $schemaDiff = $comparator->compare($fromSchema, $toSchema);
301
302 if ($renameUnused) {
303 $schemaDiff = $this->migrateUnprefixedRemovedTablesToRenames($schemaDiff);
304 $schemaDiff = $this->migrateUnprefixedRemovedFieldsToRenames($schemaDiff);
305 }
306
307 // All tables in the default connection are managed by TYPO3
308 if ($connectionName === ConnectionPool::DEFAULT_CONNECTION_NAME) {
309 return $schemaDiff;
310 }
311
312 // If there are no mapped tables return a SchemaDiff without any changes
313 // to avoid update suggestions for tables not related to TYPO3.
314 if (empty($GLOBALS['TYPO3_CONF_VARS']['DB']['TableMapping'])
315 || !is_array($GLOBALS['TYPO3_CONF_VARS']['DB']['TableMapping'])
316 ) {
317 $schemaDiff = GeneralUtility::makeInstance(SchemaDiff::class, [], [], [], $fromSchema);
318
319 return $schemaDiff;
320 }
321
322 // Collect the table names that have been mapped to this connection.
323 $tablesForConnection = array_keys(
324 array_filter(
325 $GLOBALS['TYPO3_CONF_VARS']['DB']['TableMapping'],
326 function ($tableConnectionName) use ($connectionName) {
327 return $tableConnectionName === $connectionName;
328 }
329 )
330 );
331
332 // Remove all tables that are not assigned to this connection from the diff
333 $schemaDiff->newTables = $this->removeUnrelatedTables($schemaDiff->newTables, $tablesForConnection);
334 $schemaDiff->changedTables = $this->removeUnrelatedTables($schemaDiff->changedTables, $tablesForConnection);
335 $schemaDiff->removedTables = $this->removeUnrelatedTables($schemaDiff->removedTables, $tablesForConnection);
336
337 return $schemaDiff;
338 }
339
340 /**
341 * Build the expected schema definitons from raw SQL statements.
342 *
343 * @param string $connectionName
344 * @return \Doctrine\DBAL\Schema\Schema
345 * @throws \Doctrine\DBAL\DBALException
346 * @throws \InvalidArgumentException
347 */
348 protected function buildExpectedSchemaDefinitions(string $connectionName): Schema
349 {
350 $connectionPool = GeneralUtility::makeInstance(ConnectionPool::class);
351 $connection = $connectionPool->getConnectionByName($connectionName);
352
353 $tablesForConnection = [];
354 foreach ($this->tables as $table) {
355 $tableName = $table->getName();
356
357 // Skip tables for a different connection
358 if ($connectionName !== $this->getConnectionNameForTable($tableName)) {
359 continue;
360 }
361
362 if (!array_key_exists($tableName, $tablesForConnection)) {
363 $tablesForConnection[$tableName] = $table;
364 continue;
365 }
366
367 // Merge multiple table definitions. Later definitions overrule identical
368 // columns, indexes and foreign_keys. Order of definitions is based on
369 // extension load order.
370 $currentTableDefinition = $tablesForConnection[$tableName];
371 $tablesForConnection[$tableName] = GeneralUtility::makeInstance(
372 Table::class,
373 $tableName,
374 array_merge($currentTableDefinition->getColumns(), $table->getColumns()),
375 array_merge($currentTableDefinition->getIndexes(), $table->getIndexes()),
376 array_merge($currentTableDefinition->getForeignKeys(), $table->getForeignKeys()),
377 0,
378 array_merge($currentTableDefinition->getOptions(), $table->getOptions())
379 );
380 }
381
382 $schemaConfig = GeneralUtility::makeInstance(SchemaConfig::class);
383 $schemaConfig->setName($connection->getDatabase());
384
385 return GeneralUtility::makeInstance(Schema::class, $tablesForConnection, [], $schemaConfig);
386 }
387
388 /**
389 * Parse CREATE TABLE statements into Doctrine Table objects.
390 *
391 * @param string[] $statements The SQL CREATE TABLE statements
392 * @throws \Doctrine\DBAL\Schema\SchemaException
393 * @throws \InvalidArgumentException
394 * @throws \RuntimeException
395 * @throws \TYPO3\CMS\Core\Database\Schema\Exception\StatementException
396 * @throws \TYPO3\CMS\Core\Database\Schema\Exception\UnexpectedSignalReturnValueTypeException
397 * @throws \TYPO3\CMS\Extbase\SignalSlot\Exception\InvalidSlotException
398 * @throws \TYPO3\CMS\Extbase\SignalSlot\Exception\InvalidSlotReturnException
399 */
400 protected function parseCreateTableStatements(array $statements)
401 {
402 $tables = [];
403 foreach ($statements as $statement) {
404 $createTableParser = GeneralUtility::makeInstance(Parser::class, $statement);
405
406 // We need to keep multiple table definitions at this point so
407 // that Extensions can modify existing tables.
408 $tables[] = $createTableParser->parse();
409 }
410
411 // Flatten the array of arrays by one level
412 $this->tables = array_merge(...$tables);
413 }
414
415 /**
416 * Extract the update suggestions (SQL statements) for newly added tables
417 * from the complete schema diff.
418 *
419 * @param \Doctrine\DBAL\Schema\SchemaDiff $schemaDiff
420 * @param \TYPO3\CMS\Core\Database\Connection $connection
421 * @return array
422 * @throws \InvalidArgumentException
423 */
424 protected function getNewTableUpdateSuggestions(SchemaDiff $schemaDiff, Connection $connection): array
425 {
426 // Build a new schema diff that only contains added tables
427 $addTableSchemaDiff = GeneralUtility::makeInstance(
428 SchemaDiff::class,
429 $schemaDiff->newTables,
430 [],
431 [],
432 $schemaDiff->fromSchema
433 );
434
435 $statements = $addTableSchemaDiff->toSql($connection->getDatabasePlatform());
436
437 return ['create_table' => $this->calculateUpdateSuggestionsHashes($statements)];
438 }
439
440 /**
441 * Extract the update suggestions (SQL statements) for newly added fields
442 * from the complete schema diff.
443 *
444 * @param \Doctrine\DBAL\Schema\SchemaDiff $schemaDiff
445 * @param \TYPO3\CMS\Core\Database\Connection $connection
446 * @return array
447 * @throws \Doctrine\DBAL\Schema\SchemaException
448 * @throws \InvalidArgumentException
449 */
450 protected function getNewFieldUpdateSuggestions(SchemaDiff $schemaDiff, Connection $connection): array
451 {
452 $changedTables = [];
453
454 foreach ($schemaDiff->changedTables as $index => $changedTable) {
455 if (count($changedTable->addedColumns) !== 0) {
456 // Treat each added column with a new diff to get a dedicated suggestions
457 // just for this single column.
458 foreach ($changedTable->addedColumns as $addedColumn) {
459 $changedTables[$index . ':tbl_' . $addedColumn->getName()] = GeneralUtility::makeInstance(
460 TableDiff::class,
461 $changedTable->name,
462 [$addedColumn],
463 [],
464 [],
465 [],
466 [],
467 [],
468 $schemaDiff->fromSchema->getTable($changedTable->name)
469 );
470 }
471 }
472
473 if (count($changedTable->addedIndexes) !== 0) {
474 // Treat each added index with a new diff to get a dedicated suggestions
475 // just for this index.
476 foreach ($changedTable->addedIndexes as $addedIndex) {
477 $changedTables[$index . ':idx_' . $addedIndex->getName()] = GeneralUtility::makeInstance(
478 TableDiff::class,
479 $changedTable->name,
480 [],
481 [],
482 [],
483 [$addedIndex],
484 [],
485 [],
486 $schemaDiff->fromSchema->getTable($changedTable->name)
487 );
488 }
489 }
490
491 if (count($changedTable->addedForeignKeys) !== 0) {
492 // Treat each added foreign key with a new diff to get a dedicated suggestions
493 // just for this foreign key.
494 foreach ($changedTable->addedForeignKeys as $addedForeignKey) {
495 $fkIndex = $index . ':fk_' . $addedForeignKey->getName();
496 $changedTables[$fkIndex] = GeneralUtility::makeInstance(
497 TableDiff::class,
498 $changedTable->name,
499 [],
500 [],
501 [],
502 [],
503 [],
504 [],
505 $schemaDiff->fromSchema->getTable($changedTable->name)
506 );
507 $changedTables[$fkIndex]->addedForeignKeys = [$addedForeignKey];
508 }
509 }
510 }
511
512 // Build a new schema diff that only contains added fields
513 $addFieldSchemaDiff = GeneralUtility::makeInstance(
514 SchemaDiff::class,
515 [],
516 $changedTables,
517 [],
518 $schemaDiff->fromSchema
519 );
520
521 $statements = $addFieldSchemaDiff->toSql($connection->getDatabasePlatform());
522
523 return ['add' => $this->calculateUpdateSuggestionsHashes($statements)];
524 }
525
526 /**
527 * Extract update suggestions (SQL statements) for changed fields
528 * from the complete schema diff.
529 *
530 * @param \Doctrine\DBAL\Schema\SchemaDiff $schemaDiff
531 * @param \TYPO3\CMS\Core\Database\Connection $connection
532 * @return array
533 * @throws \Doctrine\DBAL\Schema\SchemaException
534 * @throws \InvalidArgumentException
535 */
536 protected function getChangedFieldUpdateSuggestions(SchemaDiff $schemaDiff, Connection $connection): array
537 {
538 $databasePlatform = $connection->getDatabasePlatform();
539 $updateSuggestions = [];
540
541 foreach ($schemaDiff->changedTables as $index => $changedTable) {
542 if (count($changedTable->changedColumns) !== 0) {
543 // Treat each changed column with a new diff to get a dedicated suggestions
544 // just for this single column.
545 $fromTable = $schemaDiff->fromSchema->getTable($changedTable->name);
546 foreach ($changedTable->changedColumns as $changedColumn) {
547 // Field has been renamed and will be handled separately
548 if ($changedColumn->getOldColumnName()->getName() !== $changedColumn->column->getName()) {
549 continue;
550 }
551
552 // Get the current SQL declaration for the column
553 $currentColumn = $fromTable->getColumn($changedColumn->getOldColumnName()->getName());
554 $currentDeclaration = $databasePlatform->getColumnDeclarationSQL(
555 $currentColumn->getName(),
556 $currentColumn->toArray()
557 );
558
559 // Build a dedicated diff just for the current column
560 $tableDiff = GeneralUtility::makeInstance(
561 TableDiff::class,
562 $changedTable->name,
563 [],
564 [$changedColumn],
565 [],
566 [],
567 [],
568 [],
569 $schemaDiff->fromSchema->getTable($changedTable->name)
570 );
571
572 $temporarySchemaDiff = GeneralUtility::makeInstance(
573 SchemaDiff::class,
574 [],
575 [$tableDiff],
576 [],
577 $schemaDiff->fromSchema
578 );
579
580 $statements = $temporarySchemaDiff->toSql($databasePlatform);
581 foreach ($statements as $statement) {
582 $updateSuggestions['change'][md5($statement)] = $statement;
583 $updateSuggestions['change_currentValue'][md5($statement)] = $currentDeclaration;
584 }
585 }
586 }
587
588 // Treat each changed index with a new diff to get a dedicated suggestions
589 // just for this index.
590 if (count($changedTable->changedIndexes) !== 0) {
591 foreach ($changedTable->renamedIndexes as $key => $changedIndex) {
592 $indexDiff = GeneralUtility::makeInstance(
593 TableDiff::class,
594 $changedTable->name,
595 [],
596 [],
597 [],
598 [],
599 [$changedIndex],
600 [],
601 $schemaDiff->fromSchema->getTable($changedTable->name)
602 );
603
604 $temporarySchemaDiff = GeneralUtility::makeInstance(
605 SchemaDiff::class,
606 [],
607 [$indexDiff],
608 [],
609 $schemaDiff->fromSchema
610 );
611
612 $statements = $temporarySchemaDiff->toSql($databasePlatform);
613 foreach ($statements as $statement) {
614 $updateSuggestions['change'][md5($statement)] = $statement;
615 }
616 }
617 }
618
619 // Treat renamed indexes as a field change as it's a simple rename operation
620 if (count($changedTable->renamedIndexes) !== 0) {
621 // Create a base table diff without any changes, there's no constructor
622 // argument to pass in renamed columns.
623 $tableDiff = GeneralUtility::makeInstance(
624 TableDiff::class,
625 $changedTable->name,
626 [],
627 [],
628 [],
629 [],
630 [],
631 [],
632 $schemaDiff->fromSchema->getTable($changedTable->name)
633 );
634
635 // Treat each renamed index with a new diff to get a dedicated suggestions
636 // just for this index.
637 foreach ($changedTable->renamedIndexes as $key => $renamedIndex) {
638 $indexDiff = clone $tableDiff;
639 $indexDiff->renamedIndexes = [$key => $renamedIndex];
640
641 $temporarySchemaDiff = GeneralUtility::makeInstance(
642 SchemaDiff::class,
643 [],
644 [$indexDiff],
645 [],
646 $schemaDiff->fromSchema
647 );
648
649 $statements = $temporarySchemaDiff->toSql($databasePlatform);
650 foreach ($statements as $statement) {
651 $updateSuggestions['change'][md5($statement)] = $statement;
652 }
653 }
654 }
655
656 // Treat each changed foreign key with a new diff to get a dedicated suggestions
657 // just for this foreign key.
658 if (count($changedTable->changedForeignKeys) !== 0) {
659 $tableDiff = GeneralUtility::makeInstance(
660 TableDiff::class,
661 $changedTable->name,
662 [],
663 [],
664 [],
665 [],
666 [],
667 [],
668 $schemaDiff->fromSchema->getTable($changedTable->name)
669 );
670
671 foreach ($changedTable->changedForeignKeys as $changedForeignKey) {
672 $foreignKeyDiff = clone $tableDiff;
673 $foreignKeyDiff->changedForeignKeys = [$changedForeignKey];
674
675 $temporarySchemaDiff = GeneralUtility::makeInstance(
676 SchemaDiff::class,
677 [],
678 [$foreignKeyDiff],
679 [],
680 $schemaDiff->fromSchema
681 );
682
683 $statements = $temporarySchemaDiff->toSql($databasePlatform);
684 foreach ($statements as $statement) {
685 $updateSuggestions['change'][md5($statement)] = $statement;
686 }
687 }
688 }
689 }
690
691 return $updateSuggestions;
692 }
693
694 /**
695 * Extract update suggestions (SQL statements) for tables that are
696 * no longer present in the expected schema from the schema diff.
697 * In this case the update suggestions are renames of the tables
698 * with a prefix to mark them for deletion in a second sweep.
699 *
700 * @param \Doctrine\DBAL\Schema\SchemaDiff $schemaDiff
701 * @param \TYPO3\CMS\Core\Database\Connection $connection
702 * @return array
703 * @throws \Doctrine\DBAL\Schema\SchemaException
704 * @throws \InvalidArgumentException
705 */
706 protected function getUnusedTableUpdateSuggestions(SchemaDiff $schemaDiff, Connection $connection): array
707 {
708 $updateSuggestions = [];
709 foreach ($schemaDiff->changedTables as $tableDiff) {
710 // Skip tables that are not being renamed or where the new name isn't prefixed
711 // with the deletion marker.
712 if ($tableDiff->getNewName() === false
713 || !StringUtility::beginsWith($tableDiff->getNewName()->getName(), $this->deletedPrefix)
714 ) {
715 continue;
716 }
717 // Build a new schema diff that only contains this table
718 $changedFieldDiff = GeneralUtility::makeInstance(
719 SchemaDiff::class,
720 [],
721 [$tableDiff],
722 [],
723 $schemaDiff->fromSchema
724 );
725
726 $statements = $changedFieldDiff->toSql($connection->getDatabasePlatform());
727
728 foreach ($statements as $statement) {
729 $updateSuggestions['change_table'][md5($statement)] = $statement;
730 }
731 $updateSuggestions['tables_count'][md5($statements[0])] = $this->getTableRecordCount($tableDiff->name);
732 }
733
734 return $updateSuggestions;
735 }
736
737 /**
738 * Extract update suggestions (SQL statements) for fields that are
739 * no longer present in the expected schema from the schema diff.
740 * In this case the update suggestions are renames of the fields
741 * with a prefix to mark them for deletion in a second sweep.
742 *
743 * @param \Doctrine\DBAL\Schema\SchemaDiff $schemaDiff
744 * @param \TYPO3\CMS\Core\Database\Connection $connection
745 * @return array
746 * @throws \Doctrine\DBAL\Schema\SchemaException
747 * @throws \InvalidArgumentException
748 */
749 protected function getUnusedFieldUpdateSuggestions(SchemaDiff $schemaDiff, Connection $connection): array
750 {
751 $changedTables = [];
752
753 foreach ($schemaDiff->changedTables as $index => $changedTable) {
754 if (count($changedTable->changedColumns) === 0) {
755 continue;
756 }
757
758 // Treat each changed column with a new diff to get a dedicated suggestions
759 // just for this single column.
760 foreach ($changedTable->changedColumns as $changedColumn) {
761 // Field has not been renamed
762 if ($changedColumn->getOldColumnName()->getName() === $changedColumn->column->getName()) {
763 continue;
764 }
765
766 $changedTables[$index . ':' . $changedColumn->column->getName()] = GeneralUtility::makeInstance(
767 TableDiff::class,
768 $changedTable->name,
769 [],
770 [$changedColumn],
771 [],
772 [],
773 [],
774 [],
775 $schemaDiff->fromSchema->getTable($changedTable->name)
776 );
777 }
778 }
779
780 // Build a new schema diff that only contains unused fields
781 $changedFieldDiff = GeneralUtility::makeInstance(
782 SchemaDiff::class,
783 [],
784 $changedTables,
785 [],
786 $schemaDiff->fromSchema
787 );
788
789 $statements = $changedFieldDiff->toSql($connection->getDatabasePlatform());
790
791 return ['change' => $this->calculateUpdateSuggestionsHashes($statements)];
792 }
793
794 /**
795 * Extract update suggestions (SQL statements) for fields that can
796 * be removed from the complete schema diff.
797 * Fields that can be removed have been prefixed in a previous run
798 * of the schema migration.
799 *
800 * @param \Doctrine\DBAL\Schema\SchemaDiff $schemaDiff
801 * @param \TYPO3\CMS\Core\Database\Connection $connection
802 * @return array
803 * @throws \Doctrine\DBAL\Schema\SchemaException
804 * @throws \InvalidArgumentException
805 */
806 protected function getDropFieldUpdateSuggestions(SchemaDiff $schemaDiff, Connection $connection): array
807 {
808 $changedTables = [];
809
810 foreach ($schemaDiff->changedTables as $index => $changedTable) {
811 if (count($changedTable->removedColumns) !== 0) {
812 // Treat each changed column with a new diff to get a dedicated suggestions
813 // just for this single column.
814 foreach ($changedTable->removedColumns as $removedColumn) {
815 $changedTables[$index . ':tbl_' . $removedColumn->getName()] = GeneralUtility::makeInstance(
816 TableDiff::class,
817 $changedTable->name,
818 [],
819 [],
820 [$removedColumn],
821 [],
822 [],
823 [],
824 $schemaDiff->fromSchema->getTable($changedTable->name)
825 );
826 }
827 }
828
829 if (count($changedTable->removedIndexes) !== 0) {
830 // Treat each removed index with a new diff to get a dedicated suggestions
831 // just for this index.
832 foreach ($changedTable->removedIndexes as $removedIndex) {
833 $changedTables[$index . ':idx_' . $removedIndex->getName()] = GeneralUtility::makeInstance(
834 TableDiff::class,
835 $changedTable->name,
836 [],
837 [],
838 [],
839 [],
840 [],
841 [$removedIndex],
842 $schemaDiff->fromSchema->getTable($changedTable->name)
843 );
844 }
845 }
846
847 if (count($changedTable->removedForeignKeys) !== 0) {
848 // Treat each removed foreign key with a new diff to get a dedicated suggestions
849 // just for this foreign key.
850 foreach ($changedTable->removedForeignKeys as $removedForeignKey) {
851 $fkIndex = $index . ':fk_' . $removedForeignKey->getName();
852 $changedTables[$fkIndex] = GeneralUtility::makeInstance(
853 TableDiff::class,
854 $changedTable->name,
855 [],
856 [],
857 [],
858 [],
859 [],
860 [],
861 $schemaDiff->fromSchema->getTable($changedTable->name)
862 );
863 $changedTables[$fkIndex]->removedForeignKeys = [$removedForeignKey];
864 }
865 }
866 }
867
868 // Build a new schema diff that only contains removable fields
869 $removedFieldDiff = GeneralUtility::makeInstance(
870 SchemaDiff::class,
871 [],
872 $changedTables,
873 [],
874 $schemaDiff->fromSchema
875 );
876
877 $statements = $removedFieldDiff->toSql($connection->getDatabasePlatform());
878
879 return ['drop' => $this->calculateUpdateSuggestionsHashes($statements)];
880 }
881
882 /**
883 * Extract update suggestions (SQL statements) for tables that can
884 * be removed from the complete schema diff.
885 * Tables that can be removed have been prefixed in a previous run
886 * of the schema migration.
887 *
888 * @param \Doctrine\DBAL\Schema\SchemaDiff $schemaDiff
889 * @param \TYPO3\CMS\Core\Database\Connection $connection
890 * @return array
891 * @throws \Doctrine\DBAL\Schema\SchemaException
892 * @throws \InvalidArgumentException
893 */
894 protected function getDropTableUpdateSuggestions(SchemaDiff $schemaDiff, Connection $connection): array
895 {
896 $updateSuggestions = [];
897 foreach ($schemaDiff->removedTables as $removedTable) {
898 // Build a new schema diff that only contains this table
899 $tableDiff = GeneralUtility::makeInstance(
900 SchemaDiff::class,
901 [],
902 [],
903 [$removedTable],
904 $schemaDiff->fromSchema
905 );
906
907 $statements = $tableDiff->toSql($connection->getDatabasePlatform());
908 foreach ($statements as $statement) {
909 $updateSuggestions['drop_table'][md5($statement)] = $statement;
910 }
911
912 // Only store the record count for this table for the first statement,
913 // assuming that this is the actual DROP TABLE statement.
914 $updateSuggestions['tables_count'][md5($statements[0])] = $this->getTableRecordCount(
915 $removedTable->getName()
916 );
917 }
918
919 return $updateSuggestions;
920 }
921
922 /**
923 * Move tables to be removed that are not prefixed with the deleted prefix to the list
924 * of changed tables and set a new prefixed name.
925 * Without this help the Doctrine SchemaDiff has no idea if a table has been renamed and
926 * performs a drop of the old table and creates a new table, which leads to all data in
927 * the old table being lost.
928 *
929 * @param \Doctrine\DBAL\Schema\SchemaDiff $schemaDiff
930 * @return \Doctrine\DBAL\Schema\SchemaDiff
931 * @throws \InvalidArgumentException
932 */
933 protected function migrateUnprefixedRemovedTablesToRenames(SchemaDiff $schemaDiff): SchemaDiff
934 {
935 foreach ($schemaDiff->removedTables as $index => $removedTable) {
936 if (StringUtility::beginsWith($removedTable->getName(), $this->deletedPrefix)) {
937 continue;
938 }
939 $tableDiff = GeneralUtility::makeInstance(
940 TableDiff::class,
941 $removedTable->getName(),
942 $addedColumns = [],
943 $changedColumns = [],
944 $removedColumns = [],
945 $addedIndexes = [],
946 $changedIndexes = [],
947 $removedIndexes = [],
948 $fromTable = $removedTable
949 );
950
951 $tableDiff->newName = $this->deletedPrefix . $removedTable->getName();
952 $schemaDiff->changedTables[$index] = $tableDiff;
953 unset($schemaDiff->removedTables[$index]);
954 }
955
956 return $schemaDiff;
957 }
958
959 /**
960 * Scan the list of changed tables for fields that are going to be dropped. If
961 * the name of the field does not start with the deleted prefix mark the column
962 * for a rename instead of a drop operation.
963 *
964 * @param \Doctrine\DBAL\Schema\SchemaDiff $schemaDiff
965 * @return \Doctrine\DBAL\Schema\SchemaDiff
966 * @throws \InvalidArgumentException
967 */
968 protected function migrateUnprefixedRemovedFieldsToRenames(SchemaDiff $schemaDiff): SchemaDiff
969 {
970 foreach ($schemaDiff->changedTables as $tableIndex => $changedTable) {
971 if (count($changedTable->removedColumns) === 0) {
972 continue;
973 }
974
975 foreach ($changedTable->removedColumns as $columnIndex => $removedColumn) {
976 if (StringUtility::beginsWith($removedColumn->getName(), $this->deletedPrefix)) {
977 continue;
978 }
979
980 // Build a new column object with the same properties as the removed column
981 $renamedColumn = new Column(
982 $this->deletedPrefix . $removedColumn->getName(),
983 $removedColumn->getType(),
984 array_diff_key($removedColumn->toArray(), ['name', 'type'])
985 );
986
987 // Build the diff object for the column to rename
988 $columnDiff = GeneralUtility::makeInstance(
989 ColumnDiff::class,
990 $removedColumn->getName(),
991 $renamedColumn,
992 $changedProperties = [],
993 $removedColumn
994 );
995
996 // Add the column with the required rename information to the changed column list
997 $schemaDiff->changedTables[$tableIndex]->changedColumns[$columnIndex] = $columnDiff;
998
999 // Remove the column from the list of columns to be dropped
1000 unset($schemaDiff->changedTables[$tableIndex]->removedColumns[$columnIndex]);
1001 }
1002 }
1003
1004 return $schemaDiff;
1005 }
1006
1007 /**
1008 * Return the amount of records in the given table.
1009 *
1010 * @param string $tableName
1011 * @return int
1012 * @throws \InvalidArgumentException
1013 */
1014 protected function getTableRecordCount(string $tableName): int
1015 {
1016 return GeneralUtility::makeInstance(ConnectionPool::class)
1017 ->getConnectionForTable($tableName)
1018 ->count('*', $tableName, []);
1019 }
1020
1021 /**
1022 * Determine the connection name for a table
1023 *
1024 * @param string $tableName
1025 * @return string
1026 * @throws \InvalidArgumentException
1027 */
1028 protected function getConnectionNameForTable(string $tableName): string
1029 {
1030 $connectionNames = GeneralUtility::makeInstance(ConnectionPool::class)->getConnectionNames();
1031
1032 if (array_key_exists($tableName, (array)$GLOBALS['TYPO3_CONF_VARS']['DB']['TableMapping'])) {
1033 return in_array($GLOBALS['TYPO3_CONF_VARS']['DB']['TableMapping'][$tableName], $connectionNames, true)
1034 ? $GLOBALS['TYPO3_CONF_VARS']['DB']['TableMapping'][$tableName]
1035 : ConnectionPool::DEFAULT_CONNECTION_NAME;
1036 }
1037
1038 return ConnectionPool::DEFAULT_CONNECTION_NAME;
1039 }
1040
1041 /**
1042 * Replace the array keys with a md5 sum of the actual SQL statement
1043 *
1044 * @param string[] $statements
1045 * @return string[]
1046 */
1047 protected function calculateUpdateSuggestionsHashes(array $statements): array
1048 {
1049 return array_combine(array_map('md5', $statements), $statements);
1050 }
1051
1052 /**
1053 * Helper for buildSchemaDiff to filter an array of TableDiffs against a list of valid table names.
1054 *
1055 * @param TableDiff[]|Table[] $tableDiffs
1056 * @param string[] $validTableNames
1057 * @return TableDiff[]
1058 * @throws \InvalidArgumentException
1059 */
1060 protected function removeUnrelatedTables(array $tableDiffs, array $validTableNames): array
1061 {
1062 return array_filter(
1063 $tableDiffs,
1064 function ($table) use ($validTableNames) {
1065 if ($table instanceof Table) {
1066 $tableName = $table->getName();
1067 } else {
1068 $tableName = $table->newName ?: $table->name;
1069 }
1070
1071 // If the tablename has a deleted prefix strip it of before comparing
1072 // it against the list of valid table names so that drop operations
1073 // don't get removed.
1074 if (StringUtility::beginsWith($tableName, $this->deletedPrefix)) {
1075 $tableName = substr($tableName, strlen($this->deletedPrefix));
1076 }
1077 return in_array($tableName, $validTableNames, true)
1078 || in_array($this->deletedPrefix . $tableName, $validTableNames, true);
1079 }
1080 );
1081 }
1082 }