‪TYPO3CMS  ‪main
ConnectionMigrator.php
Go to the documentation of this file.
1 <?php
2 
3 declare(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 
19 
20 use Doctrine\DBAL\Exception as DBALException;
21 use Doctrine\DBAL\Platforms\AbstractPlatform;
22 use Doctrine\DBAL\Platforms\MariaDBPlatform as DoctrineMariaDBPlatform;
23 use Doctrine\DBAL\Platforms\MySQLPlatform as DoctrineMySQLPlatform;
24 use Doctrine\DBAL\Platforms\PostgreSQLPlatform as DoctrinePostgreSQLPlatform;
25 use Doctrine\DBAL\Platforms\SQLitePlatform as DoctrineSQLitePlatform;
26 use Doctrine\DBAL\Schema\Column;
27 use Doctrine\DBAL\Schema\ForeignKeyConstraint;
28 use Doctrine\DBAL\Schema\Index;
29 use Doctrine\DBAL\Schema\Schema;
30 use Doctrine\DBAL\Schema\SchemaConfig;
31 use Doctrine\DBAL\Schema\Table;
32 use Doctrine\DBAL\Schema\UniqueConstraint;
33 use Doctrine\DBAL\Types\BigIntType;
34 use Doctrine\DBAL\Types\BinaryType;
35 use Doctrine\DBAL\Types\IntegerType;
36 use Doctrine\DBAL\Types\SmallIntType;
37 use Doctrine\DBAL\Types\StringType;
38 use ‪TYPO3\CMS\Core\Database\Connection as Typo3Connection;
45 
57 class ConnectionMigrator
58 {
62  protected string $deletedPrefix = 'zzz_deleted_';
63 
67  public function __construct(
68  private readonly string $connectionName,
69  private readonly Typo3Connection $connection,
70  private readonly array $tables,
71  ) {}
72 
78  public static function create(string $connectionName, Typo3Connection $connection, array $tables): self
79  {
80  return GeneralUtility::makeInstance(
81  static::class,
82  $connectionName,
83  $connection,
84  $tables,
85  );
86  }
87 
92  public function getSchemaDiff(): Typo3SchemaDiff
93  {
94  return $this->buildSchemaDiff(false);
95  }
96 
101  public function getUpdateSuggestions(bool $remove = false): array
102  {
103  $schemaDiff = $this->buildSchemaDiff();
104  if ($remove === false) {
105  return array_merge_recursive(
106  ['add' => [], 'create_table' => [], 'change' => [], 'change_currentValue' => []],
107  $this->getNewFieldUpdateSuggestions($schemaDiff),
108  $this->getNewTableUpdateSuggestions($schemaDiff),
109  $this->getChangedFieldUpdateSuggestions($schemaDiff),
110  $this->getChangedTableOptions($schemaDiff)
111  );
112  }
113  return array_merge_recursive(
114  ['change' => [], 'change_table' => [], 'drop' => [], 'drop_table' => [], 'tables_count' => []],
115  $this->getUnusedFieldUpdateSuggestions($schemaDiff),
116  $this->getUnusedTableUpdateSuggestions($schemaDiff),
117  $this->getDropTableUpdateSuggestions($schemaDiff),
118  $this->getDropFieldUpdateSuggestions($schemaDiff)
119  );
120  }
121 
125  public function install(bool $createOnly = false): array
126  {
127  $result = [];
128  $schemaDiff = $this->buildSchemaDiff(false);
129 
130  $schemaDiff->droppedTables = [];
131  foreach ($schemaDiff->alteredTables as $key => $changedTable) {
132  $schemaDiff->alteredTables[$key]->droppedColumns = [];
133  $schemaDiff->alteredTables[$key]->droppedIndexes = [];
134 
135  // With partial ext_tables.sql files the SchemaManager is detecting
136  // existing columns as false positives for a column rename. In this
137  // context every rename is actually a new column.
138  foreach ($changedTable->renamedColumns as $columnName => $renamedColumn) {
139  $changedTable->addedColumns[$renamedColumn->getName()] = new Column(
140  $renamedColumn->getName(),
141  $renamedColumn->getType(),
142  $this->prepareColumnOptions($renamedColumn)
143  );
144  unset($changedTable->renamedColumns[$columnName]);
145  }
146 
147  if ($createOnly) {
148  // Ignore new indexes that work on columns that need changes
149  foreach ($changedTable->addedIndexes as $indexName => $addedIndex) {
150  $indexColumns = array_map(
151  static function (string $columnName): string {
152  // Strip MySQL prefix length information to get real column names
153  $columnName = preg_replace('/\‍(\d+\‍)$/', '', $columnName) ?? '';
154  // Strip sqlite '"' from column names
155  return trim($columnName, '"');
156  },
157  $addedIndex->getColumns()
158  );
159  $columnChanges = array_intersect($indexColumns, array_keys($changedTable->modifiedColumns));
160  if (!empty($columnChanges)) {
161  unset($schemaDiff->alteredTables[$key]->addedIndexes[$indexName]);
162  }
163  }
164  $schemaDiff->alteredTables[$key]->modifiedColumns = [];
165  $schemaDiff->alteredTables[$key]->modifiedIndexes = [];
166  $schemaDiff->alteredTables[$key]->renamedIndexes = [];
167  }
168  }
169 
170  $statements = $this->connection->getDatabasePlatform()->getAlterSchemaSQL($schemaDiff);
171  foreach ($statements as $statement) {
172  try {
173  $this->connection->executeStatement($statement);
174  $result[$statement] = '';
175  } catch (DBALException $e) {
176  $result[$statement] = $e->getPrevious()->getMessage();
177  }
178  }
179 
180  return $result;
181  }
182 
192  protected function buildSchemaDiff(bool $renameUnused = true): Typo3SchemaDiff
193  {
194  // Unmapped tables in a non-default connection are ignored by TYPO3
195  $tablesForConnection = [];
196  if ($this->connectionName !== ‪ConnectionPool::DEFAULT_CONNECTION_NAME) {
197  // If there are no mapped tables return a SchemaDiff without any changes
198  // to avoid update suggestions for tables not related to TYPO3.
199  if (empty(‪$GLOBALS['TYPO3_CONF_VARS']['DB']['TableMapping'] ?? null)) {
200  return new SchemaDiff(
201  // createdSchemas
202  [],
203  // droppedSchemas
204  [],
205  // createdTables
206  [],
207  // alteredTables
208  [],
209  // droppedTables:
210  [],
211  // createdSequences
212  [],
213  // alteredSequences
214  [],
215  // droppedSequences
216  [],
217  );
218  }
219 
220  // Collect the table names that have been mapped to this connection.
221  $connectionName = $this->connectionName;
223  $tablesForConnection = array_keys(
224  array_filter(
225  ‪$GLOBALS['TYPO3_CONF_VARS']['DB']['TableMapping'],
226  static function (string $tableConnectionName) use ($connectionName): bool {
227  return $tableConnectionName === $connectionName;
228  }
229  )
230  );
231 
232  // Ignore all tables without mapping if not in the default connection
233  $this->connection->getConfiguration()->setSchemaAssetsFilter(
234  static function ($assetName) use ($tablesForConnection) {
235  return in_array($assetName, $tablesForConnection, true);
236  }
237  );
238  }
239 
240  // Build the schema definitions
241  $fromSchema = $this->connection->createSchemaManager()->introspectSchema();
242  $toSchema = $this->buildExpectedSchemaDefinitions($this->connectionName);
243 
244  // Add current table options to the fromSchema
245  $tableOptions = $this->getTableOptions($this->getSchemaTableNames($fromSchema));
246  foreach ($fromSchema->getTables() as $table) {
247  $tableName = $table->getName();
248  if (!array_key_exists($tableName, $tableOptions)) {
249  continue;
250  }
251  foreach ($tableOptions[$tableName] as $optionName => $optionValue) {
252  $table->addOption($optionName, $optionValue);
253  }
254  }
255 
256  // Build SchemaDiff and handle renames of tables and columns
257  $comparator = GeneralUtility::makeInstance(Comparator::class, $this->connection->getDatabasePlatform());
258  $schemaDiff = $comparator->compareSchemas($fromSchema, $toSchema);
259  if (! $schemaDiff instanceof Typo3SchemaDiff) {
260  $schemaDiff = Typo3SchemaDiff::ensure($schemaDiff);
261  }
262  $schemaDiff = $this->migrateColumnRenamesToDistinctActions($schemaDiff);
263 
264  if ($renameUnused) {
265  $schemaDiff = $this->migrateUnprefixedRemovedTablesToRenames($schemaDiff);
266  $schemaDiff = $this->migrateUnprefixedRemovedFieldsToRenames($schemaDiff);
267  }
268 
269  // All tables in the default connection are managed by TYPO3
270  if ($this->connectionName === ‪ConnectionPool::DEFAULT_CONNECTION_NAME) {
271  return $schemaDiff;
272  }
273 
274  // Remove all tables that are not assigned to this connection from the diff
275  $schemaDiff->createdTables = $this->removeUnrelatedTables($schemaDiff->createdTables, $tablesForConnection);
276  $schemaDiff->alteredTables = $this->removeUnrelatedTables($schemaDiff->alteredTables, $tablesForConnection);
277  $schemaDiff->droppedTables = $this->removeUnrelatedTables($schemaDiff->droppedTables, $tablesForConnection);
278 
279  return $schemaDiff;
280  }
281 
288  protected function buildExpectedSchemaDefinitions(string $connectionName): Schema
289  {
291  $tablesForConnection = [];
292  foreach ($this->tables as $table) {
293  $tableName = $table->getName();
294 
295  // Skip tables for a different connection
296  if ($connectionName !== $this->getConnectionNameForTable($tableName)) {
297  continue;
298  }
299 
300  $tablesForConnection[$tableName] = $table;
301  }
302  $tablesForConnection = $this->transformTablesForDatabasePlatform($tablesForConnection, $this->connection);
303  $schemaConfig = new SchemaConfig();
304  $schemaConfig->setName($this->connection->getDatabase());
305  if (isset($this->connection->getParams()['defaultTableOptions'])) {
306  $schemaConfig->setDefaultTableOptions($this->connection->getParams()['defaultTableOptions']);
307  }
308  return new Schema($tablesForConnection, [], $schemaConfig);
309  }
310 
317  protected function getNewTableUpdateSuggestions(Typo3SchemaDiff $schemaDiff): array
318  {
319  // Build a new schema diff that only contains added tables
320  $addTableSchemaDiff = new Typo3SchemaDiff(
321  // createdSchemas
322  [],
323  // droppedSchemas
324  [],
325  // createdTables
326  $schemaDiff->getCreatedTables(),
327  // alteredTables
328  [],
329  // droppedTables
330  [],
331  // createdSequences
332  [],
333  // alteredSequences
334  [],
335  // droppedSequences
336  [],
337  );
338 
339  $statements = $this->connection->getDatabasePlatform()->getAlterSchemaSQL($addTableSchemaDiff);
340 
341  return ['create_table' => $this->calculateUpdateSuggestionsHashes($statements)];
342  }
343 
351  protected function getNewFieldUpdateSuggestions(Typo3SchemaDiff $schemaDiff): array
352  {
353  $changedTables = [];
354 
355  foreach ($schemaDiff->alteredTables as $index => $changedTable) {
356  if (count($changedTable->addedColumns) !== 0) {
357  // Treat each added column with a new diff to get a dedicated suggestions
358  // just for this single column.
359  foreach ($changedTable->addedColumns as $columnName => $addedColumn) {
360  $changedTables[$index . ':tbl_' . $columnName] = new Typo3TableDiff(
361  // oldTable
362  $this->buildQuotedTable($changedTable->getOldTable()),
363  // addedColumns
364  [$columnName => $addedColumn],
365  // modifiedColumns
366  [],
367  // droppedColumns
368  [],
369  // renamedColumns
370  [],
371  // addedIndexes
372  [],
373  // modifiedIndexes
374  [],
375  // droppedIndexes
376  [],
377  // renamedIndexes
378  [],
379  // addedForeignKeys
380  [],
381  // modifiedForeignKeys
382  [],
383  // droppedForeignKeys
384  [],
385  );
386  }
387  }
388 
389  if (count($changedTable->addedIndexes) !== 0) {
390  // Treat each added index with a new diff to get a dedicated suggestions
391  // just for this index.
392  foreach ($changedTable->addedIndexes as $indexName => $addedIndex) {
393  $changedTables[$index . ':idx_' . $indexName] = new Typo3TableDiff(
394  // oldTable
395  $this->buildQuotedTable($changedTable->getOldTable()),
396  // addedColumns
397  [],
398  // modifiedColumns
399  [],
400  // droppedColumns
401  [],
402  // renamedColumns
403  [],
404  // addedIndexes
405  [$indexName => $this->buildQuotedIndex($addedIndex)],
406  // modifiedIndexes
407  [],
408  // droppedIndexes
409  [],
410  // renamedIndexes
411  [],
412  // addedForeignKeys
413  [],
414  // modifiedForeignKeys
415  [],
416  // droppedForeignKeys
417  [],
418  );
419  }
420  }
421 
422  if (count($changedTable->addedForeignKeys) !== 0) {
423  // Treat each added foreign key with a new diff to get a dedicated suggestions
424  // just for this foreign key.
425  foreach ($changedTable->addedForeignKeys as $addedForeignKey) {
426  $fkIndex = $index . ':fk_' . $addedForeignKey->getName();
427  $changedTables[$fkIndex] = new Typo3TableDiff(
428  // oldTable
429  $this->buildQuotedTable($changedTable->getOldTable()),
430  // addedColumns
431  [],
432  // modifiedColumns
433  [],
434  // droppedColumns
435  [],
436  // renamedColumns
437  [],
438  // addedIndexes
439  [],
440  // modifiedIndexes
441  [],
442  // droppedIndexes
443  [],
444  // renamedIndexes
445  [],
446  // addedForeignKeys
447  [$this->buildQuotedForeignKey($addedForeignKey)],
448  // modifiedForeignKeys
449  [],
450  // droppedForeignKeys
451  [],
452  );
453  }
454  }
455  }
456 
457  // Build a new schema diff that only contains added fields
458  $addFieldSchemaDiff = new Typo3SchemaDiff(
459  // createdSchemas
460  [],
461  // droppedSchemas
462  [],
463  // createdTables
464  [],
465  // alteredTables
466  $changedTables,
467  // droppedTables
468  [],
469  // createdSequences
470  [],
471  // alteredSequences
472  [],
473  // droppedSequences
474  [],
475  );
476 
477  $statements = $this->connection->getDatabasePlatform()->getAlterSchemaSQL($addFieldSchemaDiff);
478 
479  return ['add' => $this->calculateUpdateSuggestionsHashes($statements)];
480  }
481 
488  protected function getChangedTableOptions(Typo3SchemaDiff $schemaDiff): array
489  {
490  $updateSuggestions = [];
491 
492  foreach ($schemaDiff->alteredTables as $index => $tableDiff) {
493  // Skip processing if this is the base TableDiff class or has no table options set.
494  if (!$tableDiff instanceof Typo3TableDiff || count($tableDiff->getTableOptions()) === 0) {
495  continue;
496  }
497 
498  $tableOptions = $tableDiff->getTableOptions();
499  $tableOptionsDiff = new Typo3TableDiff(
500  // oldTable
501  $tableDiff->getOldTable(),
502  // addedColumns
503  [],
504  // modifiedColumns
505  [],
506  // droppedColumns
507  [],
508  // renamedColumns
509  [],
510  // addedIndexes
511  [],
512  // modifiedIndexes
513  [],
514  // droppedIndexes
515  [],
516  // renamedIndexes
517  [],
518  // addedForeignKeys
519  [],
520  // modifiedForeignKeys
521  [],
522  // droppedForeignKeys
523  [],
524  );
525  $tableOptionsDiff->setTableOptions($tableOptions);
526 
527  $tableOptionsSchemaDiff = new Typo3SchemaDiff(
528  // createdSchemas
529  [],
530  // droppedSchemas
531  [],
532  // createdTables
533  [],
534  // alteredTables
535  [$index => $tableOptionsDiff],
536  // droppedTables
537  [],
538  // createdSequences
539  [],
540  // alteredSequences
541  [],
542  // droppedSequences
543  [],
544  );
545 
546  $statements = $this->connection->getDatabasePlatform()->getAlterSchemaSQL($tableOptionsSchemaDiff);
547  foreach ($statements as $statement) {
548  $updateSuggestions['change'][md5($statement)] = $statement;
549  }
550  }
551 
552  return $updateSuggestions;
553  }
554 
562  protected function getChangedFieldUpdateSuggestions(Typo3SchemaDiff $schemaDiff): array
563  {
564  $databasePlatform = $this->connection->getDatabasePlatform();
565  $updateSuggestions = [];
566 
567  foreach ($schemaDiff->alteredTables as $index => $changedTable) {
568  // Treat each changed index with a new diff to get a dedicated suggestions
569  // just for this index.
570  if (count($changedTable->modifiedIndexes) !== 0) {
571  foreach ($changedTable->modifiedIndexes as $indexName => $changedIndex) {
572  $indexDiff = new Typo3TableDiff(
573  // oldTable
574  $changedTable->getOldTable(),
575  // addedColumns
576  [],
577  // modifiedColumns
578  [],
579  // droppedColumns
580  [],
581  // renamedColumns
582  [],
583  // addedIndexes
584  [],
585  // modifiedIndexes
586  [$indexName => $changedIndex],
587  // droppedIndexes
588  [],
589  // renamedIndexes
590  [],
591  // addedForeignKeys
592  [],
593  // modifiedForeignKeys
594  [],
595  // droppedForeignKeys
596  [],
597  );
598 
599  $temporarySchemaDiff = new Typo3SchemaDiff(
600  // createdSchemas
601  [],
602  // droppedSchemas
603  [],
604  // createdTables
605  [],
606  // alteredTables
607  [$changedTable->getOldTable()->getName() => $indexDiff],
608  // droppedTables
609  [],
610  // createdSequences
611  [],
612  // alteredSequences
613  [],
614  // droppedSequences
615  [],
616  );
617 
618  $statements = $databasePlatform->getAlterSchemaSQL($temporarySchemaDiff);
619  foreach ($statements as $statement) {
620  $updateSuggestions['change'][md5($statement)] = $statement;
621  }
622  }
623  }
624 
625  // Treat renamed indexes as a field change as it's a simple rename operation
626  if (count($changedTable->renamedIndexes) !== 0) {
627  // Create a base table diff without any changes, there's no constructor
628  // argument to pass in renamed indexes.
629  $tableDiff = new Typo3TableDiff(
630  // oldTable
631  $changedTable->getOldTable(),
632  // addedColumns
633  [],
634  // modifiedColumns
635  [],
636  // droppedColumns
637  [],
638  // renamedColumns
639  [],
640  // addedIndexes
641  [],
642  // modifiedIndexes
643  [],
644  // droppedIndexes
645  [],
646  // renamedIndexes
647  [],
648  // addedForeignKeys
649  [],
650  // modifiedForeignKeys
651  [],
652  // droppedForeignKeys
653  [],
654  );
655 
656  // Treat each renamed index with a new diff to get a dedicated suggestions
657  // just for this index.
658  foreach ($changedTable->renamedIndexes as $key => $renamedIndex) {
659  $indexDiff = clone $tableDiff;
660  $indexDiff->renamedIndexes = [$key => $renamedIndex];
661 
662  $temporarySchemaDiff = new Typo3SchemaDiff(
663  // createdSchemas
664  [],
665  // droppedSchemas
666  [],
667  // createdTables
668  [],
669  // alteredTables
670  [$indexDiff->getOldTable()->getName() => $indexDiff],
671  // droppedTables
672  [],
673  // createdSequences
674  [],
675  // alteredSequences
676  [],
677  // droppedSequences
678  [],
679  );
680 
681  $statements = $databasePlatform->getAlterSchemaSQL($temporarySchemaDiff);
682  foreach ($statements as $statement) {
683  $updateSuggestions['change'][md5($statement)] = $statement;
684  }
685  }
686  }
687 
688  if (count($changedTable->modifiedColumns) !== 0) {
689  // Treat each changed column with a new diff to get a dedicated suggestions
690  // just for this single column.
691  foreach ($changedTable->modifiedColumns as $columnName => $changedColumn) {
692  // Field has been renamed and will be handled separately
693  if ($changedColumn->getOldColumn()->getName() !== $changedColumn->getNewColumn()->getName()) {
694  continue;
695  }
696 
697  if ($changedColumn->getOldColumn() !== null) {
698  $changedColumn->oldColumn = $this->buildQuotedColumn($changedColumn->oldColumn);
699  }
700 
701  // Get the current SQL declaration for the column
702  $currentColumn = $changedColumn->getOldColumn();
703  $currentDeclaration = $databasePlatform->getColumnDeclarationSQL(
704  $currentColumn->getQuotedName($this->connection->getDatabasePlatform()),
705  $currentColumn->toArray()
706  );
707 
708  // Build a dedicated diff just for the current column
709  $tableDiff = new Typo3TableDiff(
710  // oldTable
711  $this->buildQuotedTable($changedTable->getOldTable()),
712  // addedColumns
713  [],
714  // modifiedColumns
715  [$columnName => $changedColumn],
716  // droppedColumns
717  [],
718  // renamedColumns
719  [],
720  // addedIndexes
721  [],
722  // modifiedIndexes
723  [],
724  // droppedIndexes
725  [],
726  // renamedIndexes
727  [],
728  // addedForeignKeys
729  [],
730  // modifiedForeignKeys
731  [],
732  // droppedForeignKeys
733  [],
734  );
735  $temporarySchemaDiff = new Typo3SchemaDiff(
736  // createdSchemas
737  [],
738  // droppedSchemas
739  [],
740  // createdTables
741  [],
742  // alteredTables
743  [$tableDiff->getOldTable()->getName() => $tableDiff],
744  // droppedTables
745  [],
746  // createdSequences
747  [],
748  // alteredSequences
749  [],
750  // droppedSequences
751  [],
752  );
753 
754  // Get missing update statements to mimic documented Doctrine DBAL 4 SERIAL to IDENTITY column
755  // migration without loosing sequence table data.
756  // @see https://github.com/doctrine/dbal/blob/4.0.x/docs/en/how-to/postgresql-identity-migration.rst
757  $postgreSQLMigrationStatements = $this->getPostgreSQLMigrationStatements($this->connection, $changedTable, $changedColumn);
758  $indexedSearchPrerequisiteStatements = $this->getIndexedSearchTruncateTablePrerequisiteStatements($this->connection, $changedTable, $changedColumn);
759  if ($indexedSearchPrerequisiteStatements !== []) {
760  foreach ($indexedSearchPrerequisiteStatements as $statement => $reason) {
761  $updateSuggestions['change'][md5($statement)] = $statement;
762  if ($reason !== '') {
763  $updateSuggestions['change_currentValue'][md5($statement)] = $reason;
764  }
765  }
766  }
767  $statements = $databasePlatform->getAlterSchemaSQL($temporarySchemaDiff);
768  foreach ($statements as $statement) {
769  // Combine SERIAL to IDENTITY COLUMN date migration statements to the statement
770  // @todo This is a hackish way to provide data migration along with DDL changes in a connected
771  // way. There is currently no other way to archive this and again emphasizes the need to
772  // refactor the complete database analyzer stack and handling.
773  if ($postgreSQLMigrationStatements !== []) {
774  if (str_contains($statement, 'DROP DEFAULT')) {
775  $statement = rtrim($statement, '; ') . ';' . implode(';', $postgreSQLMigrationStatements);
776  }
777  if (str_contains($statement, 'ADD GENERATED BY DEFAULT AS IDENTITY')) {
778  // Due to the proper migration replacement we need to skip the Doctrine DBAL add statement
779  // which will fail anyway - and is covered by the manual update above. This ensures, that
780  // the sequence table is not dropped and recreated with empty state.
781  continue;
782  }
783  }
784  $updateSuggestions['change'][md5($statement)] = $statement;
785  $updateSuggestions['change_currentValue'][md5($statement)] = $currentDeclaration;
786  }
787  }
788  }
789 
790  // Treat each changed foreign key with a new diff to get a dedicated suggestions
791  // just for this foreign key.
792  if (count($changedTable->modifiedForeignKeys) !== 0) {
793  $tableDiff = new Typo3TableDiff(
794  // oldTable
795  $changedTable->getOldTable(),
796  // addedColumns
797  [],
798  // modifiedColumns
799  [],
800  // droppedColumns
801  [],
802  // renamedColumns
803  [],
804  // addedIndexes
805  [],
806  // modifiedIndexes
807  [],
808  // droppedIndexes
809  [],
810  // renamedIndexes
811  [],
812  // addedForeignKeys
813  [],
814  // modifiedForeignKeys
815  [],
816  // droppedForeignKeys
817  [],
818  );
819 
820  foreach ($changedTable->modifiedForeignKeys as $changedForeignKey) {
821  $foreignKeyDiff = clone $tableDiff;
822  $foreignKeyDiff->modifiedForeignKeys = [$this->buildQuotedForeignKey($changedForeignKey)];
823 
824  $temporarySchemaDiff = new Typo3SchemaDiff(
825  // createdSchemas
826  [],
827  // droppedSchemas
828  [],
829  // createdTables
830  [],
831  // alteredTables
832  [$foreignKeyDiff->getOldTable()->getName() => $foreignKeyDiff],
833  // droppedTables
834  [],
835  // createdSequences
836  [],
837  // alteredSequences
838  [],
839  // droppedSequences
840  [],
841  );
842 
843  $statements = $databasePlatform->getAlterSchemaSQL($temporarySchemaDiff);
844  foreach ($statements as $statement) {
845  $updateSuggestions['change'][md5($statement)] = $statement;
846  }
847  }
848  }
849  }
850 
851  return $updateSuggestions;
852  }
853 
863  protected function getUnusedTableUpdateSuggestions(Typo3SchemaDiff $schemaDiff): array
864  {
865  $updateSuggestions = [];
866  foreach ($schemaDiff->alteredTables as $tableName => $tableDiff) {
867  // Skip tables that are not being renamed or where the new name isn't prefixed
868  // with the deletion marker.
869  if ($tableDiff->getNewName() === null
870  || !str_starts_with($this->trimIdentifierQuotes($tableDiff->getNewName()), $this->deletedPrefix)
871  ) {
872  continue;
873  }
874 
875  $statement = $this->connection->getDatabasePlatform()->getRenameTableSQL(
876  $tableDiff->getOldTable()->getName(),
877  $tableDiff->newName
878  );
879  $updateSuggestions['change_table'][md5($statement)] = $statement;
880  $updateSuggestions['tables_count'][md5($statement)] = $this->getTableRecordCount($tableDiff->getOldTable()->getName());
881  }
882 
883  return $updateSuggestions;
884  }
885 
895  protected function getUnusedFieldUpdateSuggestions(Typo3SchemaDiff $schemaDiff): array
896  {
897  $changedTables = [];
898 
899  foreach ($schemaDiff->alteredTables as $tableName => $changedTable) {
900  if (count($changedTable->modifiedColumns) === 0) {
901  continue;
902  }
903 
904  $databasePlatform = $this->getDatabasePlatformForTable($tableName);
905 
906  // Treat each changed column with a new diff to get a dedicated suggestions
907  // just for this single column.
908  foreach ($changedTable->modifiedColumns as $oldFieldName => $changedColumn) {
909  // Field has not been renamed
910  if ($changedColumn->getOldColumn()->getName() === $changedColumn->getNewColumn()->getName()) {
911  continue;
912  }
913 
914  $renameColumnTableDiff = new Typo3TableDiff(
915  // oldTable
916  $this->buildQuotedTable($changedTable->getOldTable()),
917  // addedColumns
918  [],
919  // modifiedColumns
920  [$oldFieldName => $changedColumn],
921  // droppedColumns
922  [],
923  // renamedColumns
924  [],
925  // addedIndexes
926  [],
927  // modifiedIndexes
928  [],
929  // droppedIndexes
930  [],
931  // renamedIndexes
932  [],
933  // addedForeignKeys
934  [],
935  // modifiedForeignKeys
936  [],
937  // droppedForeignKeys
938  [],
939  );
940  if ($databasePlatform instanceof DoctrinePostgreSQLPlatform) {
941  $renameColumnTableDiff->renamedColumns[$oldFieldName] = $changedColumn->getNewColumn();
942  }
943  $changedTables[$tableName . ':' . $changedColumn->getNewColumn()->getName()] = $renameColumnTableDiff;
944 
945  if ($databasePlatform instanceof DoctrineSQLitePlatform) {
946  break;
947  }
948  }
949  }
950 
951  // Build a new schema diff that only contains unused fields
952  $changedFieldDiff = new Typo3SchemaDiff(
953  // createdSchemas
954  [],
955  // droppedSchemas
956  [],
957  // createdTables
958  [],
959  // alteredTables
960  $changedTables,
961  // droppedTables
962  [],
963  // createdSequences
964  [],
965  // alteredSequences
966  [],
967  // droppedSequences
968  [],
969  );
970 
971  $statements = $this->connection->getDatabasePlatform()->getAlterSchemaSQL($changedFieldDiff);
972 
973  return ['change' => $this->calculateUpdateSuggestionsHashes($statements)];
974  }
975 
985  protected function getDropFieldUpdateSuggestions(Typo3SchemaDiff $schemaDiff): array
986  {
987  $changedTables = [];
988 
989  foreach ($schemaDiff->alteredTables as $index => $changedTable) {
990  $isSqlite = $this->getDatabasePlatformForTable($index) instanceof DoctrineSQLitePlatform;
991  $addMoreOperations = true;
992 
993  if (count($changedTable->droppedColumns) !== 0) {
994  // Treat each changed column with a new diff to get a dedicated suggestions
995  // just for this single column.
996  foreach ($changedTable->droppedColumns as $columnName => $removedColumn) {
997  $changedTables[$index . ':tbl_' . $removedColumn->getName()] = new Typo3TableDiff(
998  // oldTable
999  $this->buildQuotedTable($changedTable->getOldTable()),
1000  // addedColumns
1001  [],
1002  // modifiedColumns
1003  [],
1004  // droppedColumns
1005  [$columnName => $this->buildQuotedColumn($removedColumn)],
1006  // renamedColumns
1007  [],
1008  // addedIndexes
1009  [],
1010  // modifiedIndexes
1011  [],
1012  // droppedIndexes
1013  [],
1014  // renamedIndexes
1015  [],
1016  // addedForeignKeys
1017  [],
1018  // modifiedForeignKeys
1019  [],
1020  // droppedForeignKeys
1021  [],
1022  );
1023  if ($isSqlite) {
1024  $addMoreOperations = false;
1025  break;
1026  }
1027  }
1028  }
1029 
1030  if ($addMoreOperations && count($changedTable->droppedIndexes) !== 0) {
1031  // Treat each removed index with a new diff to get a dedicated suggestions
1032  // just for this index.
1033  foreach ($changedTable->droppedIndexes as $indexName => $removedIndex) {
1034  $changedTables[$index . ':idx_' . $removedIndex->getName()] = new Typo3TableDiff(
1035  // oldTable
1036  $this->buildQuotedTable($changedTable->getOldTable()),
1037  // addedColumns
1038  [],
1039  // modifiedColumns
1040  [],
1041  // droppedColumns
1042  [],
1043  // renamedColumns
1044  [],
1045  // addedIndexes
1046  [],
1047  // modifiedIndexes
1048  [],
1049  // droppedIndexes
1050  [$indexName => $this->buildQuotedIndex($removedIndex)],
1051  // renamedIndexes
1052  [],
1053  // addedForeignKeys
1054  [],
1055  // modifiedForeignKeys
1056  [],
1057  // droppedForeignKeys
1058  [],
1059  );
1060  if ($isSqlite) {
1061  $addMoreOperations = false;
1062  break;
1063  }
1064  }
1065  }
1066 
1067  if ($addMoreOperations && count($changedTable->droppedForeignKeys) !== 0) {
1068  // Treat each removed foreign key with a new diff to get a dedicated suggestions
1069  // just for this foreign key.
1070  foreach ($changedTable->droppedForeignKeys as $removedForeignKey) {
1071  $fkIndex = $index . ':fk_' . $removedForeignKey->getName();
1072  $changedTables[$fkIndex] = new Typo3TableDiff(
1073  // oldTable
1074  $this->buildQuotedTable($changedTable->getOldTable()),
1075  // addedColumns
1076  [],
1077  // modifiedColumns
1078  [],
1079  // droppedColumns
1080  [],
1081  // renamedColumns
1082  [],
1083  // addedIndexes
1084  [],
1085  // modifiedIndexes
1086  [],
1087  // droppedIndexes
1088  [],
1089  // renamedIndexes
1090  [],
1091  // addedForeignKeys
1092  [],
1093  // modifiedForeignKeys
1094  [],
1095  // droppedForeignKeys
1096  [$this->buildQuotedForeignKey($removedForeignKey)],
1097  );
1098  if ($isSqlite) {
1099  break;
1100  }
1101  }
1102  }
1103  }
1104 
1105  // Build a new schema diff that only contains removable fields
1106  $removedFieldDiff = new Typo3SchemaDiff(
1107  // createdSchemas
1108  [],
1109  // droppedSchemas
1110  [],
1111  // createdTables
1112  [],
1113  // alteredTables
1114  $changedTables,
1115  // droppedTables
1116  [],
1117  // createdSequences
1118  [],
1119  // alteredSequences
1120  [],
1121  // droppedSequences
1122  [],
1123  );
1124 
1125  $statements = $this->connection->getDatabasePlatform()->getAlterSchemaSQL($removedFieldDiff);
1126 
1127  return ['drop' => $this->calculateUpdateSuggestionsHashes($statements)];
1128  }
1129 
1139  protected function getDropTableUpdateSuggestions(Typo3SchemaDiff $schemaDiff): array
1140  {
1141  $updateSuggestions = [];
1142  foreach ($schemaDiff->droppedTables as $index => $removedTable) {
1143  // Build a new schema diff that only contains this table
1144  $tableDiff = new Typo3SchemaDiff(
1145  // createdSchemas
1146  [],
1147  // droppedSchemas
1148  [],
1149  // createdTables
1150  [],
1151  // alteredTables
1152  [],
1153  // droppedTables
1154  [$index => $this->buildQuotedTable($removedTable)],
1155  // createdSequences
1156  [],
1157  // alteredSequences
1158  [],
1159  // droppedSequences
1160  [],
1161  );
1162 
1163  $statements = $this->connection->getDatabasePlatform()->getAlterSchemaSQL($tableDiff);
1164  foreach ($statements as $statement) {
1165  $updateSuggestions['drop_table'][md5($statement)] = $statement;
1166  }
1167 
1168  // Only store the record count for this table for the first statement,
1169  // assuming that this is the actual DROP TABLE statement.
1170  $updateSuggestions['tables_count'][md5($statements[0])] = $this->getTableRecordCount(
1171  $removedTable->getName()
1172  );
1173  }
1174 
1175  return $updateSuggestions;
1176  }
1177 
1187  protected function migrateUnprefixedRemovedTablesToRenames(Typo3SchemaDiff $schemaDiff): Typo3SchemaDiff
1188  {
1189  foreach ($schemaDiff->droppedTables as $index => $removedTable) {
1190  if (str_starts_with($this->trimIdentifierQuotes($removedTable->getName()), $this->deletedPrefix)) {
1191  continue;
1192  }
1193  $tableDiff = new Typo3TableDiff(
1194  // oldTable
1195  $this->buildQuotedTable($removedTable),
1196  // addedColumns
1197  [],
1198  // modifiedColumns
1199  [],
1200  // droppedColumns
1201  [],
1202  // renamedColumns
1203  [],
1204  // addedIndexes
1205  [],
1206  // modifiedIndexes
1207  [],
1208  // droppedIndexes
1209  [],
1210  // renamedIndexes
1211  [],
1212  // addedForeignKeys
1213  [],
1214  // modifiedForeignKeys
1215  [],
1216  // droppedForeignKeys
1217  [],
1218  );
1219 
1220  $tableDiff->newName = $this->connection->getDatabasePlatform()->quoteIdentifier(
1221  substr(
1222  $this->deletedPrefix . $removedTable->getName(),
1223  0,
1224  ‪PlatformInformation::getMaxIdentifierLength($this->connection->getDatabasePlatform())
1225  )
1226  );
1227  $schemaDiff->alteredTables[$index] = $tableDiff;
1228  unset($schemaDiff->droppedTables[$index]);
1229  }
1230 
1231  return $schemaDiff;
1232  }
1233 
1241  protected function migrateUnprefixedRemovedFieldsToRenames(Typo3SchemaDiff $schemaDiff): Typo3SchemaDiff
1242  {
1243  foreach ($schemaDiff->alteredTables as $tableIndex => $changedTable) {
1244  if (count($changedTable->droppedColumns) === 0) {
1245  continue;
1246  }
1247 
1248  foreach ($changedTable->droppedColumns as $columnIndex => $removedColumn) {
1249  if (str_starts_with($this->trimIdentifierQuotes($removedColumn->getName()), $this->deletedPrefix)) {
1250  continue;
1251  }
1252 
1253  // Build a new column object with the same properties as the removed column
1254  $renamedColumnName = substr(
1255  $this->deletedPrefix . $removedColumn->getName(),
1256  0,
1257  ‪PlatformInformation::getMaxIdentifierLength($this->connection->getDatabasePlatform())
1258  );
1259  $renamedColumn = new Column(
1260  $this->connection->quoteIdentifier($renamedColumnName),
1261  $removedColumn->getType(),
1262  $this->prepareColumnOptions($removedColumn)
1263  );
1264 
1265  // Build the diff object for the column to rename
1266  $columnDiff = new Typo3ColumnDiff($this->buildQuotedColumn($removedColumn), $renamedColumn);
1267 
1268  // Add the column with the required rename information to the changed column list
1269  $schemaDiff->alteredTables[$tableIndex]->modifiedColumns[$columnIndex] = $columnDiff;
1270 
1271  // Remove the column from the list of columns to be dropped
1272  unset($schemaDiff->alteredTables[$tableIndex]->droppedColumns[$columnIndex]);
1273  }
1274  }
1275 
1276  return $schemaDiff;
1277  }
1278 
1286  protected function migrateColumnRenamesToDistinctActions(Typo3SchemaDiff $schemaDiff): Typo3SchemaDiff
1287  {
1288  foreach ($schemaDiff->alteredTables as $index => $changedTable) {
1289  if (count($changedTable->getRenamedColumns()) === 0) {
1290  continue;
1291  }
1292 
1293  // Treat each renamed column with a new diff to get a dedicated
1294  // suggestion just for this single column.
1295  foreach ($changedTable->renamedColumns as $originalColumnName => $renamedColumn) {
1296  $columnOptions = $this->prepareColumnOptions($renamedColumn);
1297  $changedTable->addedColumns[$renamedColumn->getName()] = new Column(
1298  $renamedColumn->getName(),
1299  $renamedColumn->getType(),
1300  $columnOptions
1301  );
1302  $changedTable->droppedColumns[$originalColumnName] = new Column(
1303  $originalColumnName,
1304  $renamedColumn->getType(),
1305  $columnOptions
1306  );
1307 
1308  unset($changedTable->renamedColumns[$originalColumnName]);
1309  }
1310  }
1311 
1312  return $schemaDiff;
1313  }
1314 
1320  protected function getTableRecordCount(string $tableName): int
1321  {
1322  return GeneralUtility::makeInstance(ConnectionPool::class)
1323  ->getConnectionForTable($tableName)
1324  ->count('*', $tableName, []);
1325  }
1326 
1332  protected function getConnectionNameForTable(string $tableName): string
1333  {
1334  $connectionNames = GeneralUtility::makeInstance(ConnectionPool::class)->getConnectionNames();
1335 
1336  if (isset(‪$GLOBALS['TYPO3_CONF_VARS']['DB']['TableMapping'][$tableName])) {
1337  return in_array(‪$GLOBALS['TYPO3_CONF_VARS']['DB']['TableMapping'][$tableName], $connectionNames, true)
1338  ? ‪$GLOBALS['TYPO3_CONF_VARS']['DB']['TableMapping'][$tableName]
1340  }
1341 
1343  }
1344 
1351  protected function calculateUpdateSuggestionsHashes(array $statements): array
1352  {
1353  return array_combine(array_map(md5(...), $statements), $statements);
1354  }
1355 
1364  protected function removeUnrelatedTables(array $tableDiffs, array $validTableNames): array
1365  {
1366  $tableDiffs = array_filter(
1367  $tableDiffs,
1368  function (Typo3TableDiff|Table $table) use ($validTableNames): bool {
1369  if ($table instanceof Table) {
1370  $tableName = $table->getName();
1371  } else {
1372  $tableName = $table->getNewName() ?? $table->getOldTable()->getName();
1373  }
1374 
1375  // If the tablename has a deleted prefix strip it of before comparing
1376  // it against the list of valid table names so that drop operations
1377  // don't get removed.
1378  if (str_starts_with($this->trimIdentifierQuotes($tableName), $this->deletedPrefix)) {
1379  $tableName = substr($tableName, strlen($this->deletedPrefix));
1380  }
1381  return in_array($tableName, $validTableNames, true)
1382  || in_array($this->deletedPrefix . $tableName, $validTableNames, true);
1383  }
1384  );
1385  foreach ($tableDiffs as &$tableDiff) {
1386  if ($tableDiff instanceof Table) {
1387  continue;
1388  }
1389  if (! $tableDiff instanceof Typo3TableDiff) {
1390  $tableDiff = Typo3TableDiff::ensure($tableDiff);
1391  }
1392  }
1393  return $tableDiffs;
1394  }
1395 
1405  protected function transformTablesForDatabasePlatform(array $tables, Typo3Connection $connection): array
1406  {
1407  $defaultTableOptions = $connection->getParams()['defaultTableOptions'] ?? [];
1408  $tables = $this->normalizeTablesForTargetConnection($tables, $connection);
1409  foreach ($tables as &$table) {
1410  $indexes = [];
1411  foreach ($table->getIndexes() as $key => $index) {
1412  $indexName = $index->getName();
1413  // PostgreSQL and sqlite require index names to be unique per database/schema.
1414  $platform = $connection->getDatabasePlatform();
1415  if ($platform instanceof DoctrinePostgreSQLPlatform || $platform instanceof DoctrineSQLitePlatform) {
1416  $indexName = $indexName . '_' . hash('crc32b', $table->getName() . '_' . $indexName);
1417  }
1418 
1419  // Remove the length information from column names for indexes if required.
1420  $cleanedColumnNames = array_map(
1421  static function (string $columnName) use ($connection): string {
1422  $platform = $connection->getDatabasePlatform();
1423  if ($platform instanceof DoctrineMariaDBPlatform || $platform instanceof DoctrineMySQLPlatform) {
1424  // Returning the unquoted, unmodified version of the column name since
1425  // it can include the length information for BLOB/TEXT columns which
1426  // may not be quoted.
1427  return $columnName;
1428  }
1429 
1430  return $connection->quoteIdentifier(preg_replace('/\‍(\d+\‍)$/', '', $columnName));
1431  },
1432  $index->getUnquotedColumns()
1433  );
1434 
1435  $indexes[$key] = new Index(
1436  $connection->quoteIdentifier($indexName),
1437  $cleanedColumnNames,
1438  $index->isUnique(),
1439  $index->isPrimary(),
1440  $index->getFlags(),
1441  $index->getOptions()
1442  );
1443  }
1444 
1445  $table = new Table(
1446  $table->getQuotedName($connection->getDatabasePlatform()),
1447  $table->getColumns(),
1448  $indexes,
1449  [],
1450  $table->getForeignKeys(),
1451  array_merge($defaultTableOptions, $table->getOptions())
1452  );
1453  }
1454 
1455  return $tables;
1456  }
1457 
1465  protected function getTableOptions(array $tableNames): array
1466  {
1467  $tableOptions = [];
1468  $platform = $this->connection->getDatabasePlatform();
1469  if (!($platform instanceof DoctrineMariaDBPlatform || $platform instanceof DoctrineMySQLPlatform)) {
1470  foreach ($tableNames as $tableName) {
1471  $tableOptions[$tableName] = [];
1472  }
1473 
1474  return $tableOptions;
1475  }
1476 
1477  $queryBuilder = $this->connection->createQueryBuilder();
1478  $result = $queryBuilder
1479  ->select(
1480  'tables.TABLE_NAME AS table',
1481  'tables.ENGINE AS engine',
1482  'tables.ROW_FORMAT AS row_format',
1483  'tables.TABLE_COLLATION AS collate',
1484  'tables.TABLE_COMMENT AS comment',
1485  'CCSA.character_set_name AS charset'
1486  )
1487  ->from('information_schema.TABLES', 'tables')
1488  ->join(
1489  'tables',
1490  'information_schema.COLLATION_CHARACTER_SET_APPLICABILITY',
1491  'CCSA',
1492  $queryBuilder->expr()->eq(
1493  'CCSA.collation_name',
1494  $queryBuilder->quoteIdentifier('tables.table_collation')
1495  )
1496  )
1497  ->where(
1498  $queryBuilder->expr()->eq(
1499  'TABLE_TYPE',
1500  $queryBuilder->createNamedParameter('BASE TABLE')
1501  ),
1502  $queryBuilder->expr()->eq(
1503  'TABLE_SCHEMA',
1504  $queryBuilder->createNamedParameter($this->connection->getDatabase())
1505  )
1506  )
1507  ->executeQuery();
1508 
1509  while ($row = $result->fetchAssociative()) {
1510  $index = $row['table'];
1511  unset($row['table']);
1512  $tableOptions[$index] = $row;
1513  }
1514 
1515  return $tableOptions;
1516  }
1517 
1524  protected function buildQuotedTable(Table $table): Table
1525  {
1526  $databasePlatform = $this->connection->getDatabasePlatform();
1527 
1528  return new Table(
1529  $databasePlatform->quoteIdentifier($table->getName()),
1530  $table->getColumns(),
1531  $table->getIndexes(),
1532  [],
1533  $table->getForeignKeys(),
1534  $table->getOptions()
1535  );
1536  }
1537 
1544  protected function buildQuotedColumn(Column $column): Column
1545  {
1546  $databasePlatform = $this->connection->getDatabasePlatform();
1547 
1548  return new Column(
1549  $databasePlatform->quoteIdentifier($this->trimIdentifierQuotes($column->getName())),
1550  $column->getType(),
1551  $this->prepareColumnOptions($column)
1552  );
1553  }
1554 
1561  protected function buildQuotedIndex(Index $index): Index
1562  {
1563  $databasePlatform = $this->connection->getDatabasePlatform();
1564 
1565  return new Index(
1566  $databasePlatform->quoteIdentifier($index->getName()),
1567  $index->getColumns(),
1568  $index->isUnique(),
1569  $index->isPrimary(),
1570  $index->getFlags(),
1571  $index->getOptions()
1572  );
1573  }
1574 
1581  protected function buildQuotedForeignKey(ForeignKeyConstraint $index): ForeignKeyConstraint
1582  {
1583  $databasePlatform = $this->connection->getDatabasePlatform();
1584 
1585  return new ForeignKeyConstraint(
1586  $index->getLocalColumns(),
1587  $databasePlatform->quoteIdentifier($index->getForeignTableName()),
1588  $index->getForeignColumns(),
1589  $databasePlatform->quoteIdentifier($index->getName()),
1590  $index->getOptions()
1591  );
1592  }
1593 
1594  protected function prepareColumnOptions(Column $column): array
1595  {
1596  $options = $column->toArray();
1597  $platformOptions = $column->getPlatformOptions();
1598  foreach ($platformOptions as $optionName => $optionValue) {
1599  unset($options[$optionName]);
1600  if (!isset($options['platformOptions'])) {
1601  $options['platformOptions'] = [];
1602  }
1603  $options['platformOptions'][$optionName] = $optionValue;
1604  }
1605  unset($options['name'], $options['type']);
1606  return $options;
1607  }
1608 
1609  protected function getDatabasePlatformForTable(string $tableName): AbstractPlatform
1610  {
1611  $databasePlatform = GeneralUtility::makeInstance(ConnectionPool::class)->getConnectionForTable($tableName)->getDatabasePlatform();
1612  return match (true) {
1613  $databasePlatform instanceof DoctrinePostgreSQLPlatform,
1614  $databasePlatform instanceof DoctrineSQLitePlatform,
1615  $databasePlatform instanceof DoctrineMariaDBPlatform,
1616  $databasePlatform instanceof DoctrineMySQLPlatform => $databasePlatform,
1617  default => throw new \RuntimeException(
1618  sprintf(
1619  'Platform "%s" not supported for table "%s" connection.',
1620  get_class($databasePlatform),
1621  $tableName,
1622  ),
1623  1701619871
1624  ),
1625  };
1626  }
1627 
1628  protected function getSchemaTableNames(Schema $schema)
1629  {
1630  $tableNames = [];
1631  foreach ($schema->getTables() as $table) {
1632  $tableNames[] = $table->getName();
1633  }
1634  ksort($tableNames);
1635  return $tableNames;
1636  }
1637 
1655  protected function normalizeTablesForTargetConnection(array $tables, Typo3Connection $connection): array
1656  {
1657  $databasePlatform = $connection->getDatabasePlatform();
1658  array_walk($tables, function (Table &$table) use ($databasePlatform): void {
1659  $this->normalizeTableIdentifiers($databasePlatform, $table);
1660  $this->normalizeTableForMariaDBOrMySQL($databasePlatform, $table);
1661  $this->normalizeTableForPostgreSQL($databasePlatform, $table);
1662  $this->normalizeTableForSQLite($databasePlatform, $table);
1663  });
1664 
1665  return $tables;
1666  }
1667 
1672  protected function normalizeTableIdentifiers(AbstractPlatform $platform, Table &$table): void
1673  {
1674  $table = new Table(
1675  // name
1676  $platform->quoteIdentifier($this->trimIdentifierQuotes($table->getName())),
1677  // columns
1678  $this->normalizeTableColumnIdentifiers($platform, $table->getColumns()),
1679  // indexes
1680  $this->normalizeTableIndexIdentifiers($platform, $table->getIndexes()),
1681  // uniqueConstraints
1682  $this->normalizeTableUniqueConstraintIdentifiers($platform, $table->getUniqueConstraints()),
1683  // fkConstraints
1684  $this->normalizeTableForeignKeyConstraints($platform, $table->getForeignKeys()),
1685  // options
1686  $table->getOptions(),
1687  );
1688  }
1689 
1695  protected function normalizeTableForeignKeyConstraints(AbstractPlatform $platform, array $foreignKeyConstraints): array
1696  {
1697  $normalizedForeignKeyConstraints = [];
1698  foreach ($foreignKeyConstraints as $foreignKeyConstraint) {
1699  $normalizedForeignKeyConstraints[] = new ForeignKeyConstraint(
1700  // localColumnNames
1701  $foreignKeyConstraint->getQuotedLocalColumns($platform),
1702  // foreignTableName
1703  $platform->quoteIdentifier($this->trimIdentifierQuotes($foreignKeyConstraint->getForeignTableName())),
1704  // foreignColumnNames
1705  $foreignKeyConstraint->getQuotedForeignColumns($platform),
1706  // name
1707  $platform->quoteIdentifier($foreignKeyConstraint->getName()),
1708  // options
1709  $foreignKeyConstraint->getOptions(),
1710  );
1711  }
1712  return $normalizedForeignKeyConstraints;
1713  }
1714 
1721  protected function normalizeTableUniqueConstraintIdentifiers(AbstractPlatform $platform, array $uniqueConstraints): array
1722  {
1723  $normalizedUniqueConstraints = [];
1724  foreach ($uniqueConstraints as $uniqueConstraint) {
1725  $columns = $uniqueConstraint->getColumns();
1726  foreach ($columns as &$column) {
1727  $column = $platform->quoteIdentifier($this->trimIdentifierQuotes($column));
1728  }
1729  $normalizedUniqueConstraints[] = new UniqueConstraint(
1730  // name
1731  $platform->quoteIdentifier($this->trimIdentifierQuotes($uniqueConstraint->getName())),
1732  // columns
1733  $columns,
1734  // flags
1735  $uniqueConstraint->getFlags(),
1736  // options
1737  $uniqueConstraint->getOptions(),
1738  );
1739  }
1740  return $normalizedUniqueConstraints;
1741  }
1742 
1750  protected function normalizeTableIndexIdentifiers(AbstractPlatform $platform, array $indexes): array
1751  {
1752  $normalizedIndexes = [];
1753  foreach ($indexes as $index) {
1754  $columns = $index->getColumns();
1755  foreach ($columns as &$column) {
1756  $column = $platform->quoteIdentifier($this->trimIdentifierQuotes($column));
1757  }
1758  $normalizedIndexes[] = new Index(
1759  // name
1760  $platform->quoteIdentifier($this->trimIdentifierQuotes($index->getName())),
1761  // columns
1762  $columns,
1763  // isUnique
1764  $index->isUnique(),
1765  // isPrimary
1766  $index->isPrimary(),
1767  // flags
1768  $index->getFlags(),
1769  // options
1770  $index->getOptions(),
1771  );
1772  }
1773  return $normalizedIndexes;
1774  }
1775 
1783  protected function normalizeTableColumnIdentifiers(AbstractPlatform $platform, array $columns): array
1784  {
1785  $normalizedColumns = [];
1786  foreach ($columns as $column) {
1787  // It seems that since Doctrine DBAL 4 matching the autoincrement column, when defined as `UNSIGNED` is
1788  // not working anymore. The platform always create a signed autoincrement primary key, and it looks that
1789  // this code has not changed between v3 and v4. It's mysterious why we need to remove the UNSIGNED flag
1790  // for autoincrement columns for SQLite.
1791  // @todo This needs further validation and investigation.
1792  if ($column->getAutoincrement() === true && $platform instanceof DoctrineSQLitePlatform) {
1793  // @todo why do we need this with Doctrine DBAL 4 ???
1794  $column->setUnsigned(false);
1795  }
1796  $columnData = $column->toArray();
1797  unset($columnData['name'], $columnData['type']);
1798  $normalizedColumns[] = new Column(
1799  // name
1800  $platform->quoteIdentifier($this->trimIdentifierQuotes($column->getName())),
1801  // type
1802  $column->getType(),
1803  // options
1804  $columnData,
1805  );
1806  }
1807  return $normalizedColumns;
1808  }
1809 
1821  protected function normalizeTableForMariaDBOrMySQL(AbstractPlatform $databasePlatform, Table $table): void
1822  {
1823  if (!($databasePlatform instanceof DoctrineMariaDBPlatform || $databasePlatform instanceof DoctrineMySQLPlatform)) {
1824  return;
1825  }
1826 
1827  foreach ($table->getColumns() as $column) {
1828  if (!($column->getType() instanceof StringType || $column->getType() instanceof BinaryType)) {
1829  continue;
1830  }
1831  if ($column->getLength() !== null) {
1832  // Ensure not to exceed the maximum varchar or binary length
1833  if ($column->getLength() > 4000) {
1834  // @todo Should a exception be thrown for this case ?
1835  $column->setLength(4000);
1836  }
1837  continue;
1838  }
1839 
1840  // 255 has been the removed `AbstractPlatform->getVarcharDefaultLength()` and
1841  // `AbstractPlatform->getBinaryMaxLength()` value
1842  $column->setLength(255);
1843  }
1844  }
1845 
1849  protected function normalizeTableForPostgreSQL(AbstractPlatform $databasePlatform, Table $table): void
1850  {
1851  if (!($databasePlatform instanceof DoctrinePostgreSQLPlatform)) {
1852  return;
1853  }
1854 
1855  foreach ($table->getColumns() as $column) {
1856  // PostgreSQL does not support length definition for integer type fields. Therefore, we remove the pseudo
1857  // MySQL length information to avoid compare issues.
1858  if ((
1859  $column->getType() instanceof SmallIntType
1860  || $column->getType() instanceof IntegerType
1861  || $column->getType() instanceof BigIntType
1862  ) && $column->getLength() !== null
1863  ) {
1864  $column->setLength(null);
1865  }
1866  }
1867  }
1868 
1874  protected function normalizeTableForSQLite(AbstractPlatform $databasePlatform, Table $table): void
1875  {
1876  if (!($databasePlatform instanceof DoctrineSQLitePlatform)) {
1877  return;
1878  }
1879 
1880  // doctrine/dbal detects both sqlite autoincrement variants (row_id alias and autoincrement) through assumptions
1881  // which have been made. TYPO3 reads the ext_tables.sql files as MySQL/MariaDB variant, thus not setting the
1882  // autoincrement value to true for the row_id alias variant, which leads to an endless missmatch during database
1883  // comparison. This method adopts the doctrine/dbal assumption and apply it to the meta schema to mitigate
1884  // endless database compare detections in these cases.
1885  //
1886  // @see https://github.com/doctrine/dbal/commit/33555d36e7e7d07a5880e01
1887  $primaryColumns = $table->getPrimaryKey()?->getColumns() ?? [];
1888  $primaryKeyColumnCount = count($primaryColumns);
1889  $firstPrimaryKeyColumnName = $primaryColumns[0] ?? '';
1890  $singlePrimaryKeyColumn = $table->hasColumn($firstPrimaryKeyColumnName)
1891  ? $table->getColumn($firstPrimaryKeyColumnName)
1892  : null;
1893  if ($primaryKeyColumnCount === 1
1894  && $singlePrimaryKeyColumn !== null
1895  && $singlePrimaryKeyColumn->getType() instanceof IntegerType
1896  ) {
1897  $singlePrimaryKeyColumn->setAutoincrement(true);
1898  }
1899  }
1900 
1906  private function trimIdentifierQuotes(string ‪$identifier): string
1907  {
1908  return str_replace(['`', '"', '[', ']'], '', ‪$identifier);
1909  }
1910 
1919  private function getPostgreSQLMigrationStatements(Typo3Connection $connection, TableDiff $changedTable, ColumnDiff $modifiedColumn): array
1920  {
1921  $sequenceInfo = $this->getTableSequenceInformation($connection, $changedTable, $modifiedColumn);
1922  if ($sequenceInfo === null) {
1923  return [];
1924  }
1925  $newColumn = $modifiedColumn->getNewColumn();
1926  $tableName = $this->trimIdentifierQuotes($changedTable->getOldTable()->getName());
1927  $fieldName = $this->trimIdentifierQuotes($newColumn->getName());
1928  $seqId = $sequenceInfo['seqid'];
1929  $combinedStatementParts = [];
1930  // @todo use QueryBuilder to generate the upgrade statement
1931  $combinedStatementParts[] = sprintf(
1932  'UPDATE %s SET deptype = %s WHERE (classid, objid, objsubid) = (%s::regclass, %s, 0) AND deptype = %s',
1933  $connection->quoteIdentifier('pg_depend'),
1934  $connection->quote('i'),
1935  $connection->quote('pg_class'),
1936  $connection->quote((string)$seqId),
1937  $connection->quote('a'),
1938  );
1939  // mark the column as identity column
1940  // @todo use QueryBuilder to generate the upgrade statement
1941  $combinedStatementParts[] = sprintf(
1942  'UPDATE %s SET attidentity = %s WHERE attrelid = %s::regclass AND attname = %s::name',
1943  $connection->quoteIdentifier('pg_attribute'),
1944  $connection->quote('d'),
1945  $connection->quote($tableName),
1946  $connection->quote($fieldName)
1947  );
1948  return $combinedStatementParts;
1949  }
1950 
1961  private function getTableSequenceInformation(Typo3Connection $connection, TableDiff $changedTable, ColumnDiff $modifiedColumn): array|null
1962  {
1963  $oldColumn = $modifiedColumn->getOldColumn();
1964  $newColumn = $modifiedColumn->getNewColumn();
1965  $tableName = $this->trimIdentifierQuotes($changedTable->getOldTable()->getName());
1966  $fieldName = $this->trimIdentifierQuotes($newColumn->getName());
1967  $isAutoIncrementChange = ($newColumn->getAutoincrement() === true && $newColumn->getAutoincrement() !== $oldColumn->getAutoincrement());
1968 
1969  if (!($connection->getDatabasePlatform() instanceof DoctrinePostgreSQLPlatform && $isAutoIncrementChange)) {
1970  return null;
1971  }
1972  $colNum = $this->getTableFieldColumnNumber($connection, $tableName, $fieldName);
1973  if ($colNum === null) {
1974  return null;
1975  }
1976  return $this->getSequenceInfo($connection, $tableName, $fieldName, $colNum);
1977  }
1978 
1989  private function getSequenceInfo(Typo3Connection $connection, string $table, string $field, int $colNum): array|null
1990  {
1991  $quotedTable = $connection->quote($table);
1992  $colNum = $connection->quote((string)$colNum);
1993  $quotedPgClass = $connection->quote('pg_class');
1994  $depType = $connection->quote('a');
1995  // @todo Use QueryBuilder to retrieve the data
1996  $sql = sprintf(
1997  'SELECT classid as seqid, objid FROM pg_depend WHERE (refclassid, refobjid, refobjsubid) = (%s::regclass, %s::regclass, %s) AND classid = %s::regclass AND objsubid = 0 AND deptype = %s;',
1998  $quotedPgClass,
1999  $quotedTable,
2000  $colNum,
2001  $quotedPgClass,
2002  $depType
2003  );
2004  $rows = $connection->executeQuery($sql)->fetchAllAssociative();
2005  $count = count($rows);
2006  if ($count === 1) {
2007  $row = reset($rows);
2008  if (is_array($row)) {
2009  return $row;
2010  }
2011  } elseif ($count > 1) {
2012  // @todo Throw a concrete exception class
2013  throw new \RuntimeException(
2014  sprintf(
2015  'Found more than one linked sequence table for %s.%s',
2016  $table,
2017  $field
2018  ),
2019  1705673988
2020  );
2021  }
2022 
2023  return null;
2024  }
2025 
2032  private function getTableFieldColumnNumber(Typo3Connection $connection, string $table, string $field): int|null
2033  {
2034  $table = $connection->quote($table);
2035  $field = $connection->quote($field);
2036  // @todo Use QueryBuilder to retrieve the data
2037  $sql = sprintf(
2038  'SELECT attnum FROM pg_attribute WHERE attrelid = %s::regclass AND attname = %s::name;',
2039  $table,
2040  $field
2041  );
2042  $rows = $connection->executeQuery($sql)->fetchAllAssociative();
2043  $row = reset($rows);
2044  if (is_array($row)) {
2045  return (int)$row['attnum'];
2046  }
2047  return null;
2048  }
2049 
2058  private function getIndexedSearchTruncateTablePrerequisiteStatements(Typo3Connection $connection, TableDiff $changedTable, ColumnDiff $modifiedColumn): array
2059  {
2061  $tableFields = [
2062  'index_phash' => ['phash', 'phash_grouping', 'contentHash'],
2063  'index_fulltext' => ['phash'],
2064  'index_rel' => ['phash', 'wid'],
2065  'index_words' => ['wid'],
2066  'index_section' => ['phash', 'phash_t3'],
2067  'index_grlist' => ['phash', 'phash_x', 'hash_gr_list'],
2068  'index_debug' => ['phash'],
2069  ];
2070  $tableName = $this->trimIdentifierQuotes($changedTable->getOldTable()->getName());
2071  $oldType = $modifiedColumn->getOldColumn()->getType();
2072  $newType = $modifiedColumn->getNewColumn()->getType();
2073  if (($tableFields[$tableName] ?? []) === []
2074  || !($oldType instanceof IntegerType)
2075  || !($newType instanceof StringType)
2076  ) {
2077  return [];
2078  }
2079  $databasePlatform = $connection->getDatabasePlatform();
2080  if (in_array($this->trimIdentifierQuotes($modifiedColumn->getOldColumn()->getName()), $tableFields[$tableName], true)) {
2081  return [
2082  $databasePlatform->getTruncateTableSQL($changedTable->getOldTable()->getQuotedName($databasePlatform)) => 'Truncate table needed due to type change',
2083  ];
2084  }
2085  return [];
2086  }
2087 }
‪TYPO3\CMS\Core\Database\Schema
Definition: ColumnDiff.php:18
‪TYPO3\CMS\Core\Database\ConnectionPool\DEFAULT_CONNECTION_NAME
‪const DEFAULT_CONNECTION_NAME
Definition: ConnectionPool.php:50
‪TYPO3\CMS\Core\Database\Platform\PlatformInformation\getMaxIdentifierLength
‪static getMaxIdentifierLength(DoctrineAbstractPlatform $platform)
Definition: PlatformInformation.php:95
‪TYPO3\CMS\Core\Database\Schema\ColumnDiff
Definition: ColumnDiff.php:29
‪TYPO3\CMS\Core\Database\Connection
Definition: Connection.php:41
‪TYPO3\CMS\Core\Database\Schema\SchemaDiff
Definition: SchemaDiff.php:33
‪$GLOBALS
‪$GLOBALS['TYPO3_CONF_VARS']['EXTCONF']['adminpanel']['modules']
Definition: ext_localconf.php:25
‪TYPO3\CMS\Core\Database\Platform\PlatformInformation
Definition: PlatformInformation.php:33
‪TYPO3\CMS\Core\Database\ConnectionPool
Definition: ConnectionPool.php:46
‪TYPO3\CMS\Core\Utility\GeneralUtility
Definition: GeneralUtility.php:52
‪TYPO3\CMS\Core\Database\Schema\TableDiff
Definition: TableDiff.php:33
‪TYPO3\CMS\Webhooks\Message\$identifier
‪identifier readonly string $identifier
Definition: FileAddedMessage.php:37