mirror of
https://github.com/Vateron-Media/XC_VM.git
synced 2026-10-25 11:03:14 +01:00
Rollback previously swapped in older code but never touched schema
changes newer migrations applied — the rollback's own comment admitted
"migrations are forward-only, so a downgrade cannot undo them". Of the
25 core migrations, two actually DROP a column older code still reads
(013_drop_asn_allocated, 016_per_repo_update_channel); rolling back
past either without restoring it would crash the restored old code.
Add MigrationRunner::rollback(): diffs the currently-applied migration
set (the `migrations` tracking table) against the TARGET version's own
migrations/database/up/ file list — read straight out of the
already-downloaded rollback tarball via the new
ReleaseArchiveInspector (PharData/tar, no extra network call, no full
extraction) — and reverses, in descending order, every migration the
target version doesn't carry. Only migrations with a down.sql
counterpart are reversed; migrations lacking one are left applied and
logged as skipped, which is safe by design since every migration
without a down file only ever ADDs schema or business data that older
code simply never references.
Restructured src/migrations/ into database/up/ (the 25 existing
forward migrations, unchanged content) and database/down/ (18 new
reverse migrations: the two critical drops plus every mechanically-safe
ADD-column/cron-row inverse; excluded are three migrations touching
real business data — activation_codes, category_templates, lines'
custom_data — and four data UPDATEs with unrecoverable prior values).
Kept as two directories rather than a `.down.sql`-suffixed sibling of
the up file: glob('*.sql') in run() would otherwise pick up the down
file too (it still ends in .sql) and try to run it forward.
Wired into UpdateCommand's rollback case right after the tarball
download+MD5 verification (MAIN only, aborts the rollback on failure —
the pre-rollback DB backup remains the recovery path either way).
Also extends the shared TestDb harness (tests/Support/TestDb.php) to
strip MySQL-only ENGINE=/CHARSET=/COLLATE= table-option clauses under
its default SQLite backend, so MigrationRunner's real production DDL
can be exercised in tests without a live MariaDB.
116 lines
4.0 KiB
PHP
116 lines
4.0 KiB
PHP
<?php
|
|
|
|
use XcVm\Core\Database\MigrationRunner;
|
|
use PHPUnit\Framework\TestCase;
|
|
|
|
/**
|
|
* MigrationRunner::rollback() — reversing migrations on a version downgrade.
|
|
*
|
|
* Runs the real diff/down-file/tracking-row logic against a throwaway
|
|
* migrations directory and an in-memory SQLite handle (mirrors
|
|
* ModuleMigratorTest's approach for the module-side equivalent).
|
|
*/
|
|
final class MigrationRunnerRollbackTest extends TestCase {
|
|
|
|
private string $root;
|
|
private TestDb $db;
|
|
|
|
protected function setUp(): void {
|
|
$this->root = sys_get_temp_dir() . '/xcvm_rollback_' . uniqid('', true);
|
|
mkdir($this->root, 0755, true);
|
|
$this->db = new TestDb();
|
|
}
|
|
|
|
protected function tearDown(): void {
|
|
$this->rrmdir($this->root);
|
|
}
|
|
|
|
private function rrmdir(string $dir): void {
|
|
if (!is_dir($dir)) {
|
|
return;
|
|
}
|
|
foreach (scandir($dir) as $entry) {
|
|
if ($entry === '.' || $entry === '..') {
|
|
continue;
|
|
}
|
|
$path = $dir . '/' . $entry;
|
|
is_dir($path) ? $this->rrmdir($path) : unlink($path);
|
|
}
|
|
rmdir($dir);
|
|
}
|
|
|
|
private function writeDown(string $migrationName, string $sql): void {
|
|
if (!is_dir($this->root . '/down')) {
|
|
mkdir($this->root . '/down', 0755, true);
|
|
}
|
|
file_put_contents($this->root . '/down/' . $migrationName, $sql);
|
|
}
|
|
|
|
/** Mark $names as already-applied in the migrations tracking table. */
|
|
private function markApplied(array $names): void {
|
|
$this->db->query("CREATE TABLE IF NOT EXISTS `migrations` (
|
|
`id` INTEGER PRIMARY KEY AUTOINCREMENT,
|
|
`migration` VARCHAR(255) NOT NULL UNIQUE,
|
|
`applied_at` TIMESTAMP DEFAULT CURRENT_TIMESTAMP
|
|
);");
|
|
foreach ($names as $name) {
|
|
$this->db->query('INSERT INTO `migrations` (`migration`) VALUES (?);', $name);
|
|
}
|
|
}
|
|
|
|
private function appliedNames(): array {
|
|
$this->db->query('SELECT `migration` FROM `migrations`;');
|
|
return array_column($this->db->get_rows(), 'migration');
|
|
}
|
|
|
|
public function testRollbackReversesMigrationsMissingFromTargetInDescendingOrder(): void {
|
|
$this->markApplied(['001_a.sql', '002_b.sql', '003_c.sql']);
|
|
$this->db->query('CREATE TABLE probe (id INTEGER PRIMARY KEY, tag TEXT);');
|
|
$this->writeDown('002_b.sql', "INSERT INTO probe (tag) VALUES ('down-002');");
|
|
$this->writeDown('003_c.sql', "INSERT INTO probe (tag) VALUES ('down-003');");
|
|
|
|
$result = MigrationRunner::rollback($this->db, ['001_a.sql'], $this->root . '/');
|
|
|
|
$this->assertSame(['003_c.sql', '002_b.sql'], $result['reversed'], 'descending order');
|
|
$this->assertSame([], $result['skipped']);
|
|
|
|
$this->db->query('SELECT tag FROM probe ORDER BY id;');
|
|
$this->assertSame(['down-003', 'down-002'], $this->db->get_column(), 'later down ran before earlier one');
|
|
$this->assertSame(['001_a.sql'], $this->appliedNames(), 'reversed rows removed from tracking');
|
|
}
|
|
|
|
public function testRollbackSkipsMigrationsWithoutADownFile(): void {
|
|
$this->markApplied(['001_a.sql', '002_b.sql']);
|
|
// No .down.sql written for 002_b.sql.
|
|
|
|
$result = MigrationRunner::rollback($this->db, ['001_a.sql'], $this->root . '/');
|
|
|
|
$this->assertSame([], $result['reversed']);
|
|
$this->assertSame(['002_b.sql'], $result['skipped']);
|
|
$this->assertSame(['001_a.sql', '002_b.sql'], $this->appliedNames(), 'skipped row stays applied');
|
|
}
|
|
|
|
public function testRollbackReturnsEmptyWhenNothingNeedsReversing(): void {
|
|
$this->markApplied(['001_a.sql']);
|
|
|
|
$result = MigrationRunner::rollback($this->db, ['001_a.sql'], $this->root . '/');
|
|
|
|
$this->assertSame(['reversed' => [], 'skipped' => []], $result);
|
|
}
|
|
|
|
public function testRollbackHandlesMissingMigrationsTable(): void {
|
|
// Fresh DB — ensureTable() must create the table without erroring.
|
|
$result = MigrationRunner::rollback($this->db, [], $this->root . '/');
|
|
|
|
$this->assertSame(['reversed' => [], 'skipped' => []], $result);
|
|
}
|
|
|
|
public function testRollbackThrowsWhenADownFileFails(): void {
|
|
$this->markApplied(['001_a.sql']);
|
|
$this->writeDown('001_a.sql', 'THIS IS NOT VALID SQL;');
|
|
|
|
$this->expectException(\RuntimeException::class);
|
|
MigrationRunner::rollback($this->db, [], $this->root . '/');
|
|
}
|
|
}
|