Merge branch '5.2' into 5.3
[GitHub/WoltLab/WCF.git] / wcfsetup / install / files / lib / system / database / table / DatabaseTableChangeProcessor.class.php
CommitLineData
f6e43f2f
MS
1<?php
2namespace wcf\system\database\table;
3use wcf\data\package\Package;
4use wcf\system\database\editor\DatabaseEditor;
77d58141 5use wcf\system\database\table\column\AbstractIntDatabaseTableColumn;
f6e43f2f 6use wcf\system\database\table\column\IDatabaseTableColumn;
77d58141 7use wcf\system\database\table\column\TinyintDatabaseTableColumn;
f6e43f2f
MS
8use wcf\system\database\table\index\DatabaseTableForeignKey;
9use wcf\system\database\table\index\DatabaseTableIndex;
10use wcf\system\database\util\PreparedStatementConditionBuilder;
11use wcf\system\package\SplitNodeException;
12use wcf\system\WCF;
13
14/**
15 * Processes a given set of changes to database tables.
16 *
17 * @author Matthias Schmidt
77d58141 18 * @copyright 2001-2020 WoltLab GmbH
f6e43f2f
MS
19 * @license GNU Lesser General Public License <http://opensource.org/licenses/lgpl-license.php>
20 * @package WoltLabSuite\Core\System\Database\Table
21 * @since 5.2
22 */
23class DatabaseTableChangeProcessor {
24 /**
3de2e191
MS
25 * maps the registered database table column names to the ids of the packages they belong to
26 * @var int[][]
f6e43f2f 27 */
3de2e191 28 protected $columnPackageIDs = [];
f6e43f2f
MS
29
30 /**
3de2e191
MS
31 * database table columns that will be added grouped by the name of the table to which they
32 * will be added
33 * @var IDatabaseTableColumn[][]
f6e43f2f 34 */
3de2e191 35 protected $columnsToAdd = [];
f6e43f2f
MS
36
37 /**
3de2e191
MS
38 * database table columns that will be altered grouped by the name of the table to which
39 * they belong
40 * @var IDatabaseTableColumn[][]
f6e43f2f 41 */
3de2e191 42 protected $columnsToAlter = [];
f6e43f2f
MS
43
44 /**
3de2e191
MS
45 * database table columns that will be dropped grouped by the name of the table from which
46 * they will be dropped
47 * @var IDatabaseTableColumn[][]
f6e43f2f 48 */
3de2e191 49 protected $columnsToDrop = [];
f6e43f2f
MS
50
51 /**
52 * database editor to apply the relevant changes to the table layouts
53 * @var DatabaseEditor
54 */
55 protected $dbEditor;
3de2e191 56
f6e43f2f 57 /**
3de2e191
MS
58 * list of all existing tables in the used database
59 * @var string[]
f6e43f2f 60 */
3de2e191 61 protected $existingTableNames = [];
f6e43f2f
MS
62
63 /**
3de2e191
MS
64 * existing database tables
65 * @var DatabaseTable[]
f6e43f2f 66 */
3de2e191 67 protected $existingTables = [];
f6e43f2f
MS
68
69 /**
3de2e191
MS
70 * maps the registered database table index names to the ids of the packages they belong to
71 * @var int[][]
f6e43f2f 72 */
3de2e191 73 protected $indexPackageIDs = [];
f6e43f2f
MS
74
75 /**
3de2e191
MS
76 * indices that will be added grouped by the name of the table to which they will be added
77 * @var DatabaseTableIndex[][]
f6e43f2f 78 */
3de2e191 79 protected $indicesToAdd = [];
f6e43f2f
MS
80
81 /**
3de2e191
MS
82 * indices that will be dropped grouped by the name of the table from which they will be dropped
83 * @var DatabaseTableIndex[][]
f6e43f2f 84 */
3de2e191 85 protected $indicesToDrop = [];
f6e43f2f
MS
86
87 /**
88 * maps the registered database table foreign key names to the ids of the packages they belong to
89 * @var int[][]
90 */
91 protected $foreignKeyPackageIDs = [];
92
3de2e191
MS
93 /**
94 * foreign keys that will be added grouped by the name of the table to which they will be
95 * added
96 * @var DatabaseTableForeignKey[][]
97 */
98 protected $foreignKeysToAdd = [];
99
100 /**
101 * foreign keys that will be dropped grouped by the name of the table from which they will
102 * be dropped
103 * @var DatabaseTableForeignKey[][]
104 */
105 protected $foreignKeysToDrop = [];
106
f6e43f2f
MS
107 /**
108 * package that wants to apply the changes
109 * @var Package
110 */
111 protected $package;
112
3de2e191
MS
113 /**
114 * message for the split node exception thrown after the changes have been applied
115 * @var string
116 */
117 protected $splitNodeMessage = '';
118
f6e43f2f
MS
119 /**
120 * layouts/layout changes of the relevant database table
121 * @var DatabaseTable[]
122 */
123 protected $tables;
124
125 /**
126 * maps the registered database table names to the ids of the packages they belong to
127 * @var int[]
128 */
129 protected $tablePackageIDs = [];
130
3de2e191
MS
131 /**
132 * database table that will be created
133 * @var DatabaseTable[]
134 */
135 protected $tablesToCreate = [];
136
137 /**
138 * database tables that will be dropped
139 * @var DatabaseTable[]
140 */
141 protected $tablesToDrop = [];
142
f6e43f2f
MS
143 /**
144 * Creates a new instance of `DatabaseTableChangeProcessor`.
145 *
146 * @param Package $package
147 * @param DatabaseTable[] $tables
148 * @param DatabaseEditor $dbEditor
f6e43f2f 149 */
d9441130 150 public function __construct(Package $package, array $tables, DatabaseEditor $dbEditor) {
f6e43f2f
MS
151 $this->package = $package;
152
153 $tableNames = [];
154 foreach ($tables as $table) {
155 if (!($table instanceof DatabaseTable)) {
156 throw new \InvalidArgumentException("Tables must be instance of '" . DatabaseTable::class . "'");
157 }
158
159 $tableNames[] = $table->getName();
160 }
161
162 $this->tables = $tables;
163 $this->dbEditor = $dbEditor;
f6e43f2f
MS
164
165 $this->existingTableNames = $dbEditor->getTableNames();
166
167 $conditionBuilder = new PreparedStatementConditionBuilder();
168 $conditionBuilder->add('sqlTable IN (?)', [$tableNames]);
3de2e191 169 $conditionBuilder->add('isDone = ?', [1]);
f6e43f2f
MS
170
171 $sql = "SELECT *
3de2e191 172 FROM wcf" . WCF_N . "_package_installation_sql_log
f6e43f2f
MS
173 " . $conditionBuilder;
174 $statement = WCF::getDB()->prepareStatement($sql);
175 $statement->execute($conditionBuilder->getParameters());
176
177 while ($row = $statement->fetchArray()) {
178 if ($row['sqlIndex'] === '' && $row['sqlColumn'] === '') {
179 $this->tablePackageIDs[$row['sqlTable']] = $row['packageID'];
180 }
181 else if ($row['sqlIndex'] === '') {
182 $this->columnPackageIDs[$row['sqlTable']][$row['sqlColumn']] = $row['packageID'];
183 }
184 else if (substr($row['sqlIndex'], -3) === '_fk') {
185 $this->foreignKeyPackageIDs[$row['sqlTable']][$row['sqlIndex']] = $row['packageID'];
186 }
187 else {
188 $this->indexPackageIDs[$row['sqlTable']][$row['sqlIndex']] = $row['packageID'];
189 }
190 }
191 }
192
193 /**
3de2e191 194 * Adds the given index to the table.
f6e43f2f 195 *
3de2e191
MS
196 * @param string $tableName
197 * @param DatabaseTableForeignKey $foreignKey
f6e43f2f 198 */
3de2e191
MS
199 protected function addForeignKey($tableName, DatabaseTableForeignKey $foreignKey) {
200 $this->dbEditor->addForeignKey($tableName, $foreignKey->getName(), $foreignKey->getData());
f6e43f2f
MS
201 }
202
203 /**
3de2e191
MS
204 * Adds the given index to the table.
205 *
206 * @param string $tableName
207 * @param DatabaseTableIndex $index
f6e43f2f 208 */
3de2e191
MS
209 protected function addIndex($tableName, DatabaseTableIndex $index) {
210 $this->dbEditor->addIndex($tableName, $index->getName(), $index->getData());
f6e43f2f
MS
211 }
212
213 /**
3de2e191 214 * Applies all of the previously determined changes to achieve the desired database layout.
f6e43f2f 215 *
3de2e191 216 * @throws SplitNodeException if any change has been applied
f6e43f2f 217 */
3de2e191
MS
218 protected function applyChanges() {
219 $appliedAnyChange = false;
f6e43f2f 220
3de2e191
MS
221 foreach ($this->tablesToCreate as $table) {
222 $appliedAnyChange = true;
223
224 $this->prepareTableLog($table);
225 $this->createTable($table);
226 $this->finalizeTableLog($table);
f6e43f2f
MS
227 }
228
3de2e191
MS
229 foreach ($this->tablesToDrop as $table) {
230 $appliedAnyChange = true;
f6e43f2f 231
3de2e191
MS
232 $this->dropTable($table);
233 $this->deleteTableLog($table);
f6e43f2f
MS
234 }
235
3de2e191
MS
236 $columnTables = array_unique(array_merge(
237 array_keys($this->columnsToAdd),
238 array_keys($this->columnsToAlter),
239 array_keys($this->columnsToDrop)
240 ));
241 foreach ($columnTables as $tableName) {
242 $appliedAnyChange = true;
f6e43f2f 243
3de2e191
MS
244 $columnsToAdd = $this->columnsToAdd[$tableName] ?? [];
245 $columnsToAlter = $this->columnsToAlter[$tableName] ?? [];
246 $columnsToDrop = $this->columnsToDrop[$tableName] ?? [];
247
248 foreach ($columnsToAdd as $column) {
249 $this->prepareColumnLog($tableName, $column);
f6e43f2f 250 }
f6e43f2f 251
3de2e191
MS
252 $this->applyColumnChanges(
253 $tableName,
254 $columnsToAdd,
255 $columnsToAlter,
256 $columnsToDrop
257 );
258
259 foreach ($columnsToAdd as $column) {
260 $this->finalizeColumnLog($tableName, $column);
261 }
262
263 foreach ($columnsToDrop as $column) {
264 $this->deleteColumnLog($tableName, $column);
f6e43f2f 265 }
f6e43f2f
MS
266 }
267
c64a33b4 268 foreach ($this->foreignKeysToDrop as $tableName => $foreignKeys) {
3de2e191
MS
269 foreach ($foreignKeys as $foreignKey) {
270 $appliedAnyChange = true;
271
c64a33b4
MS
272 $this->dropForeignKey($tableName, $foreignKey);
273 $this->deleteForeignKeyLog($tableName, $foreignKey);
3de2e191 274 }
f6e43f2f
MS
275 }
276
c64a33b4 277 foreach ($this->foreignKeysToAdd as $tableName => $foreignKeys) {
3de2e191
MS
278 foreach ($foreignKeys as $foreignKey) {
279 $appliedAnyChange = true;
280
c64a33b4
MS
281 $this->prepareForeignKeyLog($tableName, $foreignKey);
282 $this->addForeignKey($tableName, $foreignKey);
283 $this->finalizeForeignKeyLog($tableName, $foreignKey);
f6e43f2f
MS
284 }
285 }
286
c8280fb8 287 foreach ($this->indicesToDrop as $tableName => $indices) {
f6e43f2f 288 foreach ($indices as $index) {
3de2e191
MS
289 $appliedAnyChange = true;
290
c8280fb8
MS
291 $this->dropIndex($tableName, $index);
292 $this->deleteIndexLog($tableName, $index);
f6e43f2f
MS
293 }
294 }
295
c8280fb8 296 foreach ($this->indicesToAdd as $tableName => $indices) {
3de2e191
MS
297 foreach ($indices as $index) {
298 $appliedAnyChange = true;
299
c8280fb8
MS
300 $this->prepareIndexLog($tableName, $index);
301 $this->addIndex($tableName, $index);
302 $this->finalizeIndexLog($tableName, $index);
f6e43f2f 303 }
3de2e191
MS
304 }
305
306 if ($appliedAnyChange) {
307 throw new SplitNodeException($this->splitNodeMessage);
f6e43f2f
MS
308 }
309 }
310
311 /**
3de2e191 312 * Adds, alters, and drop columns of the same table.
f6e43f2f 313 *
3de2e191
MS
314 * Before a column is dropped, all of its foreign keys are dropped.
315 *
316 * @param string $tableName
317 * @param IDatabaseTableColumn[] $addedColumns
318 * @param IDatabaseTableColumn[] $alteredColumns
319 * @param IDatabaseTableColumn[] $droppedColumns
f6e43f2f 320 */
3de2e191
MS
321 protected function applyColumnChanges($tableName, array $addedColumns, array $alteredColumns, array $droppedColumns) {
322 $dropForeignKeys = [];
323
324 $columnData = [];
325 foreach ($droppedColumns as $droppedColumn) {
326 $columnData[$droppedColumn->getName()] = [
327 'action' => 'drop'
328 ];
329
330 foreach ($this->getExistingTable($tableName)->getForeignKeys() as $foreignKey) {
331 if (in_array($droppedColumn->getName(), $foreignKey->getColumns())) {
4b07e8c2 332 $dropForeignKeys[] = $foreignKey;
3de2e191
MS
333 }
334 }
335 }
336 foreach ($addedColumns as $addedColumn) {
337 $columnData[$addedColumn->getName()] = [
338 'action' => 'add',
339 'data' => $addedColumn->getData()
340 ];
341 }
342 foreach ($alteredColumns as $alteredColumn) {
343 $columnData[$alteredColumn->getName()] = [
344 'action' => 'alter',
345 'data' => $alteredColumn->getData(),
346 'oldColumnName' => $alteredColumn->getName()
347 ];
f6e43f2f
MS
348 }
349
3de2e191
MS
350 if (!empty($columnData)) {
351 foreach ($dropForeignKeys as $foreignKey) {
4b07e8c2
MS
352 $this->dropForeignKey($tableName, $foreignKey);
353 $this->deleteForeignKeyLog($tableName, $foreignKey);
3de2e191
MS
354 }
355
356 $this->dbEditor->alterColumns($tableName, $columnData);
357 }
358 }
359
360 /**
361 * Calculates all of the necessary changes to be executed.
362 */
363 protected function calculateChanges() {
f6e43f2f 364 foreach ($this->tables as $table) {
3de2e191
MS
365 $tableName = $table->getName();
366
f6e43f2f 367 if ($table->willBeDropped()) {
3de2e191
MS
368 if (in_array($tableName, $this->existingTableNames)) {
369 $this->tablesToDrop[] = $table;
370
d9441130
MS
371 $this->splitNodeMessage .= "Dropped table '{$tableName}'.";
372 break;
3de2e191
MS
373 }
374 else if (isset($this->tablePackageIDs[$tableName])) {
375 $this->deleteTableLog($table);
f6e43f2f
MS
376 }
377 }
3de2e191 378 else if (!in_array($tableName, $this->existingTableNames)) {
e00d344b
MS
379 if ($table instanceof PartialDatabaseTable) {
380 throw new \LogicException("Partial table '{$tableName}' cannot be created.");
381 }
382
3de2e191
MS
383 $this->tablesToCreate[] = $table;
384
d9441130
MS
385 $this->splitNodeMessage .= "Created table '{$tableName}'.";
386 break;
f6e43f2f
MS
387 }
388 else {
389 // calculate difference between tables
3de2e191 390 $existingTable = $this->getExistingTable($tableName);
f6e43f2f 391 $existingColumns = $existingTable->getColumns();
f6e43f2f 392
f6e43f2f 393 foreach ($table->getColumns() as $column) {
3de2e191
MS
394 if ($column->willBeDropped()) {
395 if (isset($existingColumns[$column->getName()])) {
396 if (!isset($this->columnsToDrop[$tableName])) {
397 $this->columnsToDrop[$tableName] = [];
398 }
399 $this->columnsToDrop[$tableName][] = $column;
400 }
401 else if (isset($this->columnPackageIDs[$tableName][$column->getName()])) {
402 $this->deleteColumnLog($tableName, $column);
403 }
f6e43f2f 404 }
3de2e191
MS
405 else if (!isset($existingColumns[$column->getName()])) {
406 if (!isset($this->columnsToAdd[$tableName])) {
407 $this->columnsToAdd[$tableName] = [];
f6e43f2f 408 }
3de2e191
MS
409 $this->columnsToAdd[$tableName][] = $column;
410 }
689cb90b 411 else if ($this->diffColumns($existingColumns[$column->getName()], $column)) {
3de2e191
MS
412 if (!isset($this->columnsToAlter[$tableName])) {
413 $this->columnsToAlter[$tableName] = [];
f6e43f2f 414 }
3de2e191 415 $this->columnsToAlter[$tableName][] = $column;
f6e43f2f
MS
416 }
417 }
418
3de2e191
MS
419 // all column-related changes are executed in one query thus break
420 // here and not within the previous loop
d9441130 421 if (!empty($this->columnsToAdd) || !empty($this->columnsToAlter) || !empty($this->columnsToDrop)) {
3de2e191
MS
422 $this->splitNodeMessage .= "Altered columns of table '{$tableName}'.";
423 break;
424 }
f6e43f2f 425
3de2e191 426 $existingForeignKeys = $existingTable->getForeignKeys();
f6e43f2f
MS
427 foreach ($table->getForeignKeys() as $foreignKey) {
428 $matchingExistingForeignKey = null;
429 foreach ($existingForeignKeys as $existingForeignKey) {
c64a33b4 430 if (empty(array_diff($foreignKey->getDiffData(), $existingForeignKey->getDiffData()))) {
f6e43f2f
MS
431 $matchingExistingForeignKey = $existingForeignKey;
432 break;
433 }
434 }
435
436 if ($foreignKey->willBeDropped()) {
437 if ($matchingExistingForeignKey !== null) {
3de2e191
MS
438 if (!isset($this->foreignKeysToDrop[$tableName])) {
439 $this->foreignKeysToDrop[$tableName] = [];
440 }
441 $this->foreignKeysToDrop[$tableName][] = $foreignKey;
442
d9441130
MS
443 $this->splitNodeMessage .= "Dropped foreign key '{$tableName}." . implode(',', $foreignKey->getColumns()) . "'.";
444 break 2;
3de2e191
MS
445 }
446 else if (isset($this->foreignKeyPackageIDs[$tableName][$foreignKey->getName()])) {
447 $this->deleteForeignKeyLog($tableName, $foreignKey);
f6e43f2f
MS
448 }
449 }
450 else if ($matchingExistingForeignKey === null) {
dc4b5734
MS
451 // If the referenced database table does not already exists, delay the
452 // foreign key creation until after the referenced table has been created.
453 if (!in_array($foreignKey->getReferencedTable(), $this->existingTableNames)) {
454 continue;
455 }
456
3de2e191
MS
457 if (!isset($this->foreignKeysToAdd[$tableName])) {
458 $this->foreignKeysToAdd[$tableName] = [];
459 }
460 $this->foreignKeysToAdd[$tableName][] = $foreignKey;
461
d9441130
MS
462 $this->splitNodeMessage .= "Added foreign key '{$tableName}." . implode(',', $foreignKey->getColumns()) . "'.";
463 break 2;
f6e43f2f 464 }
5963110a 465 else if (!empty(array_diff($foreignKey->getData(), $matchingExistingForeignKey->getData()))) {
c64a33b4
MS
466 if (!isset($this->foreignKeysToDrop[$tableName])) {
467 $this->foreignKeysToDrop[$tableName] = [];
468 }
469 $this->foreignKeysToDrop[$tableName][] = $matchingExistingForeignKey;
470
471 if (!isset($this->foreignKeysToAdd[$tableName])) {
472 $this->foreignKeysToAdd[$tableName] = [];
473 }
474 $this->foreignKeysToAdd[$tableName][] = $foreignKey;
475
476 $this->splitNodeMessage .= "Replaced foreign key '{$tableName}." . implode(',', $foreignKey->getColumns()) . "'.";
477 break 2;
478 }
f6e43f2f
MS
479 }
480
3de2e191 481 $existingIndices = $existingTable->getIndices();
f6e43f2f
MS
482 foreach ($table->getIndices() as $index) {
483 $matchingExistingIndex = null;
484 foreach ($existingIndices as $existingIndex) {
c8280fb8 485 if (!$this->diffIndices($existingIndex, $index)) {
f6e43f2f
MS
486 $matchingExistingIndex = $existingIndex;
487 break;
488 }
489 }
490
491 if ($index->willBeDropped()) {
492 if ($matchingExistingIndex !== null) {
3de2e191
MS
493 if (!isset($this->indicesToDrop[$tableName])) {
494 $this->indicesToDrop[$tableName] = [];
495 }
a9e05ee0 496 $this->indicesToDrop[$tableName][] = $matchingExistingIndex;
3de2e191 497
d9441130
MS
498 $this->splitNodeMessage .= "Dropped index '{$tableName}." . implode(',', $index->getColumns()) . "'.";
499 break 2;
3de2e191
MS
500 }
501 else if (isset($this->indexPackageIDs[$tableName][$index->getName()])) {
502 $this->deleteIndexLog($tableName, $index);
f6e43f2f
MS
503 }
504 }
c8280fb8
MS
505 else if ($matchingExistingIndex !== null) {
506 // updating index type and index columns is supported with an
507 // explicit index name is given (automatically generated index
508 // names are not deterministic)
509 if (!$index->hasGeneratedName() && !empty(array_diff($matchingExistingIndex->getData(), $index->getData()))) {
510 if (!isset($this->indicesToDrop[$tableName])) {
511 $this->indicesToDrop[$tableName] = [];
512 }
513 $this->indicesToDrop[$tableName][] = $matchingExistingIndex;
514
515 if (!isset($this->indicesToAdd[$tableName])) {
516 $this->indicesToAdd[$tableName] = [];
517 }
518 $this->indicesToAdd[$tableName][] = $index;
519 }
520 }
521 else {
3de2e191
MS
522 if (!isset($this->indicesToAdd[$tableName])) {
523 $this->indicesToAdd[$tableName] = [];
524 }
525 $this->indicesToAdd[$tableName][] = $index;
526
d9441130
MS
527 $this->splitNodeMessage .= "Added index '{$tableName}." . implode(',', $index->getColumns()) . "'.";
528 break 2;
f6e43f2f
MS
529 }
530 }
f6e43f2f
MS
531 }
532 }
3de2e191
MS
533 }
534
535 /**
536 * Checks for any pending log entries for the package and either marks them as done or
537 * deletes them so that after this method finishes, there are no more undone log entries
538 * for the package.
539 */
540 protected function checkPendingLogEntries() {
541 $sql = "SELECT *
542 FROM wcf" . WCF_N . "_package_installation_sql_log
543 WHERE packageID = ?
544 AND isDone = ?";
545 $statement = WCF::getDB()->prepareStatement($sql);
546 $statement->execute([$this->package->packageID, 0]);
547
548 $doneEntries = $undoneEntries = [];
549 while ($row = $statement->fetchArray()) {
550 // table
551 if ($row['sqlIndex'] === '' && $row['sqlColumn'] === '') {
552 if (in_array($row['sqlTable'], $this->existingTableNames)) {
553 $doneEntries[] = $row;
554 }
555 else {
556 $undoneEntries[] = $row;
557 }
558 }
559 // column
560 else if ($row['sqlIndex'] === '') {
561 if (isset($this->getExistingTable($row['sqlTable'])->getColumns()[$row['sqlColumn']])) {
562 $doneEntries[] = $row;
563 }
564 else {
565 $undoneEntries[] = $row;
566 }
567 }
568 // foreign key
569 else if (substr($row['sqlIndex'], -3) === '_fk') {
570 if (isset($this->getExistingTable($row['sqlTable'])->getForeignKeys()[$row['sqlIndex']])) {
571 $doneEntries[] = $row;
572 }
573 else {
574 $undoneEntries[] = $row;
575 }
576 }
577 // index
578 else {
579 if (isset($this->getExistingTable($row['sqlTable'])->getIndices()[$row['sqlIndex']])) {
580 $doneEntries[] = $row;
581 }
582 else {
583 $undoneEntries[] = $row;
584 }
585 }
586 }
587
588 WCF::getDB()->beginTransaction();
589 foreach ($doneEntries as $entry) {
590 $this->finalizeLog($entry);
591 }
f6e43f2f 592
3de2e191
MS
593 // to achieve a consistent state, undone log entries will be deleted here even though
594 // they might be re-created later to ensure that after this method finishes, there are
595 // no more undone entries in the log for the relevant package
596 foreach ($undoneEntries as $entry) {
597 $this->deleteLog($entry);
598 }
599 WCF::getDB()->commitTransaction();
f6e43f2f
MS
600 }
601
d996e907
MS
602 /**
603 * Creates a done log entry for the given foreign key.
604 *
605 * @param string $tableName
606 * @param DatabaseTableForeignKey $foreignKey
607 */
608 protected function createForeignKeyLog($tableName, DatabaseTableForeignKey $foreignKey) {
609 $sql = "INSERT INTO wcf" . WCF_N . "_package_installation_sql_log
610 (packageID, sqlTable, sqlIndex, isDone)
611 VALUES (?, ?, ?, ?)";
612 $statement = WCF::getDB()->prepareStatement($sql);
613
614 $statement->execute([
615 $this->package->packageID,
616 $tableName,
617 $foreignKey->getName(),
618 1
619 ]);
620 }
621
f6e43f2f 622 /**
3de2e191 623 * Creates the given table.
f6e43f2f 624 *
3de2e191 625 * @param DatabaseTable $table
f6e43f2f 626 */
3de2e191 627 protected function createTable(DatabaseTable $table) {
36357b9c
AE
628 $hasPrimaryKey = false;
629 $columnData = array_map(function(IDatabaseTableColumn $column) use (&$hasPrimaryKey) {
630 $data = $column->getData();
631 if (isset($data['key']) && $data['key'] === 'PRIMARY') {
632 $hasPrimaryKey = true;
633 }
634
3de2e191 635 return [
36357b9c 636 'data' => $data,
3de2e191 637 'name' => $column->getName()
f6e43f2f 638 ];
3de2e191
MS
639 }, $table->getColumns());
640 $indexData = array_map(function(DatabaseTableIndex $index) {
641 return [
642 'data' => $index->getData(),
643 'name' => $index->getName()
f6e43f2f 644 ];
3de2e191
MS
645 }, $table->getIndices());
646
36357b9c
AE
647 // Auto columns are implicitly defined as the primary key by MySQL.
648 if ($hasPrimaryKey) {
649 $indexData = array_filter($indexData, function($key) {
650 return $key !== 'PRIMARY';
651 }, ARRAY_FILTER_USE_KEY);
652 }
653
3de2e191
MS
654 $this->dbEditor->createTable($table->getName(), $columnData, $indexData);
655
656 foreach ($table->getForeignKeys() as $foreignKey) {
dc4b5734
MS
657 // Only try to create the foreign key if the referenced database table already exists.
658 // If it will be created later on, delay the foreign key creation until after the
659 // referenced table has been created.
660 if (
661 in_array($foreignKey->getReferencedTable(), $this->existingTableNames)
662 || $foreignKey->getReferencedTable() === $table->getName()
663 ) {
664 $this->dbEditor->addForeignKey($table->getName(), $foreignKey->getName(), $foreignKey->getData());
665
666 // foreign keys need to be explicitly logged for proper uninstallation
667 $this->createForeignKeyLog($table->getName(), $foreignKey);
668 }
f6e43f2f 669 }
3de2e191
MS
670 }
671
672 /**
673 * Deletes the log entry for the given column.
674 *
675 * @param string $tableName
676 * @param IDatabaseTableColumn $column
677 */
678 protected function deleteColumnLog($tableName, IDatabaseTableColumn $column) {
679 $this->deleteLog(['sqlTable' => $tableName, 'sqlColumn' => $column->getName()]);
680 }
681
682 /**
683 * Deletes the log entry for the given foreign key.
684 *
685 * @param string $tableName
686 * @param DatabaseTableForeignKey $foreignKey
687 */
688 protected function deleteForeignKeyLog($tableName, DatabaseTableForeignKey $foreignKey) {
689 $this->deleteLog(['sqlTable' => $tableName, 'sqlIndex' => $foreignKey->getName()]);
690 }
691
692 /**
693 * Deletes the log entry for the given index.
694 *
695 * @param string $tableName
696 * @param DatabaseTableIndex $index
697 */
698 protected function deleteIndexLog($tableName, DatabaseTableIndex $index) {
699 $this->deleteLog(['sqlTable' => $tableName, 'sqlIndex' => $index->getName()]);
700 }
701
702 /**
703 * Deletes a log entry.
704 *
705 * @param array $data
706 */
707 protected function deleteLog(array $data) {
708 $sql = "DELETE FROM wcf" . WCF_N . "_package_installation_sql_log
709 WHERE packageID = ?
710 AND sqlTable = ?
711 AND sqlColumn = ?
712 AND sqlIndex = ?";
713 $statement = WCF::getDB()->prepareStatement($sql);
714
715 $statement->execute([
716 $this->package->packageID,
717 $data['sqlTable'],
718 $data['sqlColumn'] ?? '',
719 $data['sqlIndex'] ?? ''
720 ]);
721 }
722
723 /**
724 * Deletes all log entry related to the given table.
725 *
726 * @param DatabaseTable $table
727 */
728 protected function deleteTableLog(DatabaseTable $table) {
729 $sql = "DELETE FROM wcf" . WCF_N . "_package_installation_sql_log
730 WHERE packageID = ?
731 AND sqlTable = ?";
732 $statement = WCF::getDB()->prepareStatement($sql);
733
734 $statement->execute([
735 $this->package->packageID,
736 $table->getName()
737 ]);
738 }
739
689cb90b
MS
740 /**
741 * Returns `true` if the two columns differ.
742 *
743 * @param IDatabaseTableColumn $oldColumn
744 * @param IDatabaseTableColumn $newColumn
745 * @return bool
746 */
747 protected function diffColumns(IDatabaseTableColumn $oldColumn, IDatabaseTableColumn $newColumn) {
77d58141
MS
748 $diff = array_diff($oldColumn->getData(), $newColumn->getData());
749 if (!empty($diff)) {
1feb7d64 750 // see https://github.com/WoltLab/WCF/pull/3167
77d58141 751 if (
1feb7d64 752 array_key_exists('length', $diff)
77d58141
MS
753 && $oldColumn instanceof AbstractIntDatabaseTableColumn
754 && (
755 !($oldColumn instanceof TinyintDatabaseTableColumn)
756 || $oldColumn->getLength() != 1
757 )
758 ) {
759 unset($diff['length']);
760 }
761
762 if (!empty($diff)) {
763 return true;
764 }
689cb90b
MS
765 }
766
c519eeea
MS
767 // default type has to be checked explicitly for `null` to properly detect changing
768 // from no default value (`null`) and to an empty string as default value (and vice
769 // versa)
770 if ($oldColumn->getDefaultValue() === null || $newColumn->getDefaultValue() === null) {
771 return $oldColumn->getDefaultValue() !== $newColumn->getDefaultValue();
772 }
773
774 // for all other cases, use weak comparison so that `'1'` (from database) and `1`
775 // (from script PIP) match, for example
776 return $oldColumn->getDefaultValue() != $newColumn->getDefaultValue();
689cb90b
MS
777 }
778
c8280fb8
MS
779 /**
780 * Returns `true` if the two indices differ.
781 *
782 * @param DatabaseTableIndex $oldIndex
783 * @param DatabaseTableIndex $newIndex
784 * @return bool
785 */
786 protected function diffIndices(DatabaseTableIndex $oldIndex, DatabaseTableIndex $newIndex) {
787 if ($newIndex->hasGeneratedName()) {
788 return !empty(array_diff($oldIndex->getData(), $newIndex->getData()));
789 }
790
791 return $oldIndex->getName() !== $newIndex->getName();
792 }
793
3de2e191
MS
794 /**
795 * Drops the given foreign key.
796 *
797 * @param string $tableName
798 * @param DatabaseTableForeignKey $foreignKey
799 */
800 protected function dropForeignKey($tableName, DatabaseTableForeignKey $foreignKey) {
801 $this->dbEditor->dropForeignKey($tableName, $foreignKey->getName());
802 $this->dbEditor->dropIndex($tableName, $foreignKey->getName());
803 }
804
805 /**
806 * Drops the given index.
807 *
808 * @param string $tableName
809 * @param DatabaseTableIndex $index
810 */
811 protected function dropIndex($tableName, DatabaseTableIndex $index) {
812 $this->dbEditor->dropIndex($tableName, $index->getName());
813 }
814
815 /**
816 * Drops the given table.
817 *
818 * @param DatabaseTable $table
819 */
820 protected function dropTable(DatabaseTable $table) {
821 $this->dbEditor->dropTable($table->getName());
822 }
823
824 /**
825 * Finalizes the log entry for the creation of the given column.
826 *
827 * @param string $tableName
828 * @param IDatabaseTableColumn $column
829 */
830 protected function finalizeColumnLog($tableName, IDatabaseTableColumn $column) {
831 $this->finalizeLog(['sqlTable' => $tableName, 'sqlColumn' => $column->getName()]);
832 }
833
834 /**
835 * Finalizes the log entry for adding the given index.
836 *
837 * @param string $tableName
838 * @param DatabaseTableForeignKey $foreignKey
839 */
840 protected function finalizeForeignKeyLog($tableName, DatabaseTableForeignKey $foreignKey) {
841 $this->finalizeLog(['sqlTable' => $tableName, 'sqlIndex' => $foreignKey->getName()]);
842 }
843
844 /**
845 * Finalizes the log entry for adding the given index.
846 *
847 * @param string $tableName
848 * @param DatabaseTableIndex $index
849 */
850 protected function finalizeIndexLog($tableName, DatabaseTableIndex $index) {
851 $this->finalizeLog(['sqlTable' => $tableName, 'sqlIndex' => $index->getName()]);
852 }
853
854 /**
855 * Finalizes a log entry after the relevant change has been executed.
856 *
857 * @param array $data
858 */
859 protected function finalizeLog(array $data) {
860 $sql = "UPDATE wcf" . WCF_N . "_package_installation_sql_log
861 SET isDone = ?
862 WHERE packageID = ?
863 AND sqlTable = ?
864 AND sqlColumn = ?
865 AND sqlIndex = ?";
866 $statement = WCF::getDB()->prepareStatement($sql);
867
868 $statement->execute([
869 1,
870 $this->package->packageID,
871 $data['sqlTable'],
872 $data['sqlColumn'] ?? '',
873 $data['sqlIndex'] ?? ''
874 ]);
875 }
876
877 /**
878 * Finalizes the log entry for the creation of the given table.
879 *
880 * @param DatabaseTable $table
881 */
882 protected function finalizeTableLog(DatabaseTable $table) {
883 $this->finalizeLog(['sqlTable' => $table->getName()]);
884 }
885
886 /**
887 * Returns the id of the package to with the given column belongs to. If there is no specific
888 * log entry for the given column, the table log is checked and the relevant package id of
889 * the whole table is returned. If the package of the table is also unknown, `null` is returned.
890 *
891 * @param DatabaseTable $table
892 * @param IDatabaseTableColumn $column
893 * @return null|int
894 */
895 protected function getColumnPackageID(DatabaseTable $table, IDatabaseTableColumn $column) {
896 if (isset($this->columnPackageIDs[$table->getName()][$column->getName()])) {
897 return $this->columnPackageIDs[$table->getName()][$column->getName()];
898 }
899 else if (isset($this->tablePackageIDs[$table->getName()])) {
900 return $this->tablePackageIDs[$table->getName()];
f6e43f2f
MS
901 }
902
3de2e191
MS
903 return null;
904 }
905
906 /**
907 * Returns the `DatabaseTable` object for the table with the given name.
908 *
909 * @param string $tableName
910 * @return DatabaseTable
911 */
912 protected function getExistingTable($tableName) {
913 if (!isset($this->existingTables[$tableName])) {
914 $this->existingTables[$tableName] = DatabaseTable::createFromExistingTable($this->dbEditor, $tableName);
f6e43f2f 915 }
3de2e191
MS
916
917 return $this->existingTables[$tableName];
f6e43f2f
MS
918 }
919
920 /**
3de2e191
MS
921 * Returns the id of the package to with the given foreign key belongs to. If there is no specific
922 * log entry for the given foreign key, the table log is checked and the relevant package id of
923 * the whole table is returned. If the package of the table is also unknown, `null` is returned.
f6e43f2f
MS
924 *
925 * @param DatabaseTable $table
3de2e191
MS
926 * @param DatabaseTableForeignKey $foreignKey
927 * @return null|int
f6e43f2f 928 */
3de2e191
MS
929 protected function getForeignKeyPackageID(DatabaseTable $table, DatabaseTableForeignKey $foreignKey) {
930 if (isset($this->foreignKeyPackageIDs[$table->getName()][$foreignKey->getName()])) {
931 return $this->foreignKeyPackageIDs[$table->getName()][$foreignKey->getName()];
f6e43f2f 932 }
3de2e191
MS
933 else if (isset($this->tablePackageIDs[$table->getName()])) {
934 return $this->tablePackageIDs[$table->getName()];
f6e43f2f
MS
935 }
936
3de2e191 937 return null;
f6e43f2f
MS
938 }
939
940 /**
3de2e191
MS
941 * Returns the id of the package to with the given index belongs to. If there is no specific
942 * log entry for the given index, the table log is checked and the relevant package id of
943 * the whole table is returned. If the package of the table is also unknown, `null` is returned.
f6e43f2f
MS
944 *
945 * @param DatabaseTable $table
3de2e191
MS
946 * @param DatabaseTableIndex $index
947 * @return null|int
f6e43f2f 948 */
3de2e191
MS
949 protected function getIndexPackageID(DatabaseTable $table, DatabaseTableIndex $index) {
950 if (isset($this->indexPackageIDs[$table->getName()][$index->getName()])) {
951 return $this->indexPackageIDs[$table->getName()][$index->getName()];
f6e43f2f 952 }
3de2e191
MS
953 else if (isset($this->tablePackageIDs[$table->getName()])) {
954 return $this->tablePackageIDs[$table->getName()];
f6e43f2f
MS
955 }
956
3de2e191
MS
957 return null;
958 }
959
960 /**
961 * Prepares the log entry for the creation of the given column.
962 *
963 * @param string $tableName
964 * @param IDatabaseTableColumn $column
965 */
966 protected function prepareColumnLog($tableName, IDatabaseTableColumn $column) {
967 $this->prepareLog(['sqlTable' => $tableName, 'sqlColumn' => $column->getName()]);
968 }
969
970 /**
971 * Prepares the log entry for adding the given foreign key.
972 *
973 * @param string $tableName
974 * @param DatabaseTableForeignKey $foreignKey
975 */
976 protected function prepareForeignKeyLog($tableName, DatabaseTableForeignKey $foreignKey) {
977 $this->prepareLog(['sqlTable' => $tableName, 'sqlIndex' => $foreignKey->getName()]);
978 }
979
980 /**
981 * Prepares the log entry for adding the given index.
982 *
983 * @param string $tableName
984 * @param DatabaseTableIndex $index
985 */
986 protected function prepareIndexLog($tableName, DatabaseTableIndex $index) {
987 $this->prepareLog(['sqlTable' => $tableName, 'sqlIndex' => $index->getName()]);
988 }
989
990 /**
991 * Prepares a log entry before the relevant change has been executed.
992 *
993 * @param array $data
994 */
995 protected function prepareLog(array $data) {
996 $sql = "INSERT INTO wcf" . WCF_N . "_package_installation_sql_log
997 (packageID, sqlTable, sqlColumn, sqlIndex, isDone)
998 VALUES (?, ?, ?, ?, ?)";
999 $statement = WCF::getDB()->prepareStatement($sql);
1000
1001 $statement->execute([
1002 $this->package->packageID,
1003 $data['sqlTable'],
1004 $data['sqlColumn'] ?? '',
1005 $data['sqlIndex'] ?? '',
1006 0
1007 ]);
1008 }
1009
1010 /**
1011 * Prepares the log entry for the creation of the given table.
1012 *
1013 * @param DatabaseTable $table
1014 */
1015 protected function prepareTableLog(DatabaseTable $table) {
1016 $this->prepareLog(['sqlTable' => $table->getName()]);
1017 }
1018
1019 /**
1020 * Processes all tables and updates the current table layouts to match the specified layouts.
1021 *
1022 * @throws \RuntimeException if validation of the required layout changes fails
1023 */
1024 public function process() {
1025 $this->checkPendingLogEntries();
1026
1027 $errors = $this->validate();
1028 if (!empty($errors)) {
1029 throw new \RuntimeException(WCF::getLanguage()->getDynamicVariable('wcf.acp.package.error.databaseChange', [
1030 'errors' => $errors
1031 ]));
f6e43f2f 1032 }
3de2e191
MS
1033
1034 $this->calculateChanges();
1035
1036 $this->applyChanges();
f6e43f2f
MS
1037 }
1038
1039 /**
1040 * Checks if the relevant table layout changes can be executed and returns an array with information
3de2e191 1041 * on all validation errors.
f6e43f2f
MS
1042 *
1043 * @return array
1044 */
1045 public function validate() {
1046 $errors = [];
1047 foreach ($this->tables as $table) {
1048 if ($table->willBeDropped()) {
1049 if (in_array($table->getName(), $this->existingTableNames)) {
1050 if (!isset($this->tablePackageIDs[$table->getName()])) {
1051 $errors[] = [
1052 'tableName' => $table->getName(),
1053 'type' => 'unregisteredTableDrop'
1054 ];
1055 }
1056 else if ($this->tablePackageIDs[$table->getName()] !== $this->package->packageID) {
1057 $errors[] = [
1058 'tableName' => $table->getName(),
1059 'type' => 'foreignTableDrop'
1060 ];
1061 }
1062 }
1063 }
f9515e9f 1064 else {
1a922b48 1065 $existingTable = null;
f9515e9f
MS
1066 if (in_array($table->getName(), $this->existingTableNames)) {
1067 if (!isset($this->tablePackageIDs[$table->getName()])) {
1068 $errors[] = [
1069 'tableName' => $table->getName(),
1070 'type' => 'unregisteredTableChange',
1071 ];
1072 }
1073 else {
1074 $existingTable = DatabaseTable::createFromExistingTable($this->dbEditor, $table->getName());
1075 $existingColumns = $existingTable->getColumns();
1076 $existingIndices = $existingTable->getIndices();
1077 $existingForeignKeys = $existingTable->getForeignKeys();
1078
1079 foreach ($table->getColumns() as $column) {
1080 if (isset($existingColumns[$column->getName()])) {
1081 $columnPackageID = $this->getColumnPackageID($table, $column);
1082 if ($column->willBeDropped()) {
1083 if ($columnPackageID !== $this->package->packageID) {
1084 $errors[] = [
1085 'columnName' => $column->getName(),
1086 'tableName' => $table->getName(),
1087 'type' => 'foreignColumnDrop',
1088 ];
1089 }
1090 }
1091 else if ($columnPackageID !== $this->package->packageID) {
f6e43f2f
MS
1092 $errors[] = [
1093 'columnName' => $column->getName(),
1094 'tableName' => $table->getName(),
f9515e9f 1095 'type' => 'foreignColumnChange',
f6e43f2f
MS
1096 ];
1097 }
1098 }
f6e43f2f 1099 }
f9515e9f
MS
1100
1101 foreach ($table->getIndices() as $index) {
1102 foreach ($existingIndices as $existingIndex) {
1103 if (empty(array_diff($index->getData(), $existingIndex->getData()))) {
1104 if ($index->willBeDropped()) {
1105 if ($this->getIndexPackageID($table, $index) !== $this->package->packageID) {
1106 $errors[] = [
1107 'columnNames' => implode(',', $existingIndex->getColumns()),
1108 'tableName' => $table->getName(),
1109 'type' => 'foreignIndexDrop',
1110 ];
1111 }
f6e43f2f 1112 }
f9515e9f
MS
1113
1114 continue 2;
f6e43f2f 1115 }
f6e43f2f
MS
1116 }
1117 }
f9515e9f
MS
1118
1119 foreach ($table->getForeignKeys() as $foreignKey) {
1120 foreach ($existingForeignKeys as $existingForeignKey) {
1121 if (empty(array_diff($foreignKey->getData(), $existingForeignKey->getData()))) {
1122 if ($foreignKey->willBeDropped()) {
1123 if ($this->getForeignKeyPackageID($table, $foreignKey) !== $this->package->packageID) {
1124 $errors[] = [
1125 'columnNames' => implode(',', $existingForeignKey->getColumns()),
1126 'tableName' => $table->getName(),
1127 'type' => 'foreignForeignKeyDrop',
1128 ];
1129 }
f6e43f2f 1130 }
f9515e9f
MS
1131
1132 continue 2;
f6e43f2f 1133 }
f9515e9f
MS
1134 }
1135 }
1136 }
1137 }
1138
1139 foreach ($table->getIndices() as $index) {
0a2e3270
MS
1140 foreach ($index->getColumns() as $indexColumn) {
1141 $column = $this->getColumnByName($indexColumn, $table, $existingTable);
1142 if ($column === null) {
1143 if (!$index->willBeDropped()) {
f9515e9f
MS
1144 $errors[] = [
1145 'columnName' => $indexColumn,
1146 'columnNames' => implode(',', $index->getColumns()),
1147 'tableName' => $table->getName(),
0a2e3270 1148 'type' => 'nonexistingColumnInIndex',
f9515e9f 1149 ];
f6e43f2f
MS
1150 }
1151 }
0a2e3270
MS
1152 else if (
1153 $index->getType() === DatabaseTableIndex::PRIMARY_TYPE
1154 && !$index->willBeDropped()
1155 && !$column->isNotNull()
1156 ) {
1157 $errors[] = [
1158 'columnName' => $indexColumn,
1159 'columnNames' => implode(',', $index->getColumns()),
1160 'tableName' => $table->getName(),
1161 'type' => 'nullColumnInPrimaryIndex',
1162 ];
1163 }
f6e43f2f
MS
1164 }
1165 }
998e9224
MS
1166
1167 foreach ($table->getForeignKeys() as $foreignKey) {
1168 $referencedTableExists = in_array($foreignKey->getReferencedTable(), $this->existingTableNames);
1169 foreach ($this->tables as $processedTable) {
1170 if ($processedTable->getName() === $foreignKey->getReferencedTable()) {
1171 $referencedTableExists = !$processedTable->willBeDropped();
1172 }
1173 }
1174
1175 if (!$referencedTableExists) {
1176 $errors[] = [
1177 'columnNames' => implode(',', $foreignKey->getColumns()),
1178 'referencedTableName' => $foreignKey->getReferencedTable(),
1179 'tableName' => $table->getName(),
1180 'type' => 'unknownTableInForeignKey',
1181 ];
1182 }
1183 }
f6e43f2f
MS
1184 }
1185 }
1186
1187 return $errors;
1188 }
f9515e9f
MS
1189
1190 /**
1191 * Returns the column with the given name from the given table.
1192 *
1193 * @param string $columnName
1194 * @param DatabaseTable $updateTable
1195 * @param DatabaseTable|null $existingTable
1196 * @return IDatabaseTableColumn|null
1197 * @since 5.2.10
1198 */
1199 protected function getColumnByName($columnName, DatabaseTable $updateTable, DatabaseTable $existingTable = null) {
1200 foreach ($updateTable->getColumns() as $column) {
7c8a1b33 1201 if ($column->getName() === $columnName) {
f9515e9f
MS
1202 return $column;
1203 }
1204 }
1205
2834fb0a
MS
1206 if ($existingTable) {
1207 foreach ($existingTable->getColumns() as $column) {
1208 if ($column->getName() === $columnName) {
1209 return $column;
1210 }
f9515e9f
MS
1211 }
1212 }
1213
1214 return null;
1215 }
f6e43f2f 1216}