Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
38 changes: 38 additions & 0 deletions src/Shared/Infrastructure/Database/Migrations.php
Original file line number Diff line number Diff line change
Expand Up @@ -232,6 +232,44 @@ public static function getMissingForeignKeys(): array
* @return int How many were created
*/
private static function addMissingForeignKeys(array $keys): int
{
// Rows a missing constraint would have prevented are already in the
// database — that is the whole premise of repairing an install that
// lost its keys. InnoDB validates existing rows when a constraint is
// added, so with checks on it refuses the very keys most worth putting
// back, and the failure is only logged: the constraint is quietly lost.
//
// update() and Restore both happen to set this already, so the repair
// worked from there and nowhere else. Establish it here instead of
// depending on the caller, since the caller cannot know it is needed.
$checksWereOn = ((int) Connection::preparedFetchValue(
'SELECT @@FOREIGN_KEY_CHECKS AS value'
)) === 1;
if ($checksWereOn) {
Connection::execute('SET FOREIGN_KEY_CHECKS = 0');
}

try {
return self::addForeignKeysUnchecked($keys);
} finally {
if ($checksWereOn) {
Connection::execute('SET FOREIGN_KEY_CHECKS = 1');
}
}
}

/**
* Add the absent constraints, assuming FK checks are already off.
*
* @param array<array{
* name: string, table: string, columns: array<string>,
* refTable: string, refColumns: array<string>,
* onUpdate: string, onDelete: string
* }> $keys Foreign keys to add if missing
*
* @return int How many were added
*/
private static function addForeignKeysUnchecked(array $keys): int
{
$existing = [];
foreach (self::captureForeignKeys() as $key) {
Expand Down
53 changes: 53 additions & 0 deletions tests/backend/Core/Database/MigrationsTest.php
Original file line number Diff line number Diff line change
Expand Up @@ -660,6 +660,59 @@ public function testRecordMigrationPromotesFailureToApplied(): void
Connection::preparedExecute("DELETE FROM _migrations WHERE filename = ?", [$testFilename]);
}

public function testAConstraintIsRestoredEvenWhenItsRowsViolateIt(): void
{
if (!self::$dbConnected) {
$this->markTestSkipped('Database connection required');
}

// The premise of repairing an install that lost its keys is that rows
// the constraint would have prevented are already there. InnoDB
// validates existing rows when a constraint is added, so with FK checks
// on it refuses exactly the keys most worth restoring (errno 1452) and
// addMissingForeignKeys() only logs it — the constraint is lost.
$before = Migrations::captureForeignKeys();
$target = null;
foreach ($before as $key) {
if ($key['table'] === 'texttags' && count($key['columns']) === 1) {
$target = $key;
break;
}
}
if ($target === null) {
$this->markTestSkipped('No single-column texttags constraint to exercise');
}

Connection::execute('SET FOREIGN_KEY_CHECKS = 0');
Connection::preparedExecute(
'INSERT IGNORE INTO texttags (TtTxID, TtT2ID) VALUES (?, ?)',
[999123, 999123]
);
Connection::execute('SET FOREIGN_KEY_CHECKS = 1');

try {
Migrations::dropAllForeignKeys();
Migrations::restoreForeignKeys($before);

$names = array_map(
static fn(array $k): string => $k['table'] . '.' . $k['name'],
Migrations::captureForeignKeys()
);
$this->assertContains(
$target['table'] . '.' . $target['name'],
$names,
'A constraint its own rows violate must still be restored'
);
} finally {
Connection::execute('SET FOREIGN_KEY_CHECKS = 0');
Connection::preparedExecute(
'DELETE FROM texttags WHERE TtTxID = ? AND TtT2ID = ?',
[999123, 999123]
);
Connection::execute('SET FOREIGN_KEY_CHECKS = 1');
}
}

public function testForeignKeysSurviveDropAndRestore(): void
{
if (!self::$dbConnected) {
Expand Down