Skip to content

Commit b6768d1

Browse files
committed
test: cli tests for reset flag
1 parent 285b630 commit b6768d1

File tree

4 files changed

+328
-9
lines changed

4 files changed

+328
-9
lines changed

src/Cli/ExecuteCommand.php

Lines changed: 4 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -65,7 +65,10 @@ public function run(?ArgumentValueList $arguments = null):void {
6565
$resetNumber = $arguments->get("reset")->get();
6666
if(!$resetNumber) {
6767
$lastKey = array_key_last($migrationFileList);
68-
$resetNumber = $migrator->extractNumberFromFilename($migrationFileList[$lastKey]);
68+
$lastNumber = $migrator->extractNumberFromFilename($migrationFileList[$lastKey]);
69+
// When no number provided, execute only the latest migration by
70+
// setting the reset point to one less than the latest number.
71+
$resetNumber = max(0, $lastNumber - 1);
6972
}
7073
$resetNumber = (int)$resetNumber;
7174
}

src/Migration/Migrator.php

Lines changed: 10 additions & 6 deletions
Original file line numberDiff line numberDiff line change
@@ -125,19 +125,23 @@ public function getMigrationFileList():array {
125125

126126
/** @param array<string> $fileList */
127127
public function checkFileListOrder(array $fileList):void {
128-
$counter = 0;
128+
$previousNumber = null;
129129
$sequence = [];
130130

131131
foreach($fileList as $file) {
132-
$counter++;
133132
$migrationNumber = $this->extractNumberFromFilename($file);
134133
$sequence []= $migrationNumber;
135134

136-
if($counter !== $migrationNumber) {
137-
throw new MigrationSequenceOrderException(
138-
"Missing: $counter"
139-
);
135+
if(!is_null($previousNumber)) {
136+
if($migrationNumber === $previousNumber) {
137+
throw new MigrationSequenceOrderException("Duplicate: $migrationNumber");
138+
}
139+
if($migrationNumber < $previousNumber) {
140+
throw new MigrationSequenceOrderException("Out of order: $migrationNumber before $previousNumber");
141+
}
140142
}
143+
144+
$previousNumber = $migrationNumber;
141145
}
142146
}
143147

Lines changed: 192 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,192 @@
1+
<?php
2+
namespace Gt\Database\Test\Cli;
3+
4+
use Gt\Cli\Argument\ArgumentValueList;
5+
use Gt\Cli\Stream;
6+
use Gt\Database\Cli\ExecuteCommand;
7+
use Gt\Database\Connection\Settings;
8+
use Gt\Database\Database;
9+
use Gt\Database\Test\Helper\Helper;
10+
use PHPUnit\Framework\TestCase;
11+
use SplFileObject;
12+
13+
class ExecuteCommandTest extends TestCase {
14+
const MIGRATION_CREATE
15+
= "create table `test` (`id` int primary key, `name` varchar(32))";
16+
const MIGRATION_ALTER = "alter table `test` add `new_column` varchar(32)";
17+
18+
private function createProjectDir():string {
19+
$root = Helper::getTmpDir();
20+
// Ensure empty directory for each test run
21+
$project = implode(DIRECTORY_SEPARATOR, [$root, uniqid("proj-")]);
22+
mkdir($project, 0775, true);
23+
return $project;
24+
}
25+
26+
private function writeConfigIni(string $projectRoot, string $sqlitePath, string $queryPath = "query", string $migrationPath = "_migration"):void {
27+
$config = [];
28+
$config[] = "[database]";
29+
$config[] = "driver = sqlite";
30+
$config[] = "schema = \"" . str_replace("\\", "/", $sqlitePath) . "\"";
31+
$config[] = "query_path = $queryPath";
32+
$config[] = "migration_path = $migrationPath";
33+
$config[] = "username = \"\"";
34+
$config[] = "password = \"\"";
35+
file_put_contents($projectRoot . DIRECTORY_SEPARATOR . "config.ini", implode(PHP_EOL, $config));
36+
}
37+
38+
private function createMigrations(string $projectRoot, int $count):array {
39+
$queryDir = $projectRoot . DIRECTORY_SEPARATOR . "query";
40+
$migDir = $queryDir . DIRECTORY_SEPARATOR . "_migration";
41+
mkdir($migDir, 0775, true);
42+
43+
$fileList = [];
44+
for($i = 1; $i <= $count; $i++) {
45+
$filename = str_pad((string)$i, 4, "0", STR_PAD_LEFT) . "-" . uniqid() . ".sql";
46+
$path = $migDir . DIRECTORY_SEPARATOR . $filename;
47+
if($i === 1) {
48+
$sql = self::MIGRATION_CREATE;
49+
}
50+
else {
51+
$sql = str_replace("`new_column`", "`new_column_{$i}`", self::MIGRATION_ALTER);
52+
}
53+
file_put_contents($path, $sql);
54+
$fileList[] = $path;
55+
}
56+
return $fileList;
57+
}
58+
59+
private function makeStreamFiles():array {
60+
$dir = Helper::getTmpDir();
61+
// Ensure the directory exists to prevent tempnam() notices
62+
if(!is_dir($dir)) {
63+
mkdir($dir, 0775, true);
64+
}
65+
$in = tempnam($dir, "cli-in-");
66+
$out = tempnam($dir, "cli-out-");
67+
$err = tempnam($dir, "cli-err-");
68+
// Ensure files exist
69+
file_put_contents($in, "");
70+
file_put_contents($out, "");
71+
file_put_contents($err, "");
72+
$stream = new Stream($in, $out, $err);
73+
return [
74+
"stream" => $stream,
75+
"in" => $in,
76+
"out" => $out,
77+
"err" => $err,
78+
];
79+
}
80+
81+
private function readFromFiles(string $outPath, string $errPath):array {
82+
$out = new SplFileObject($outPath, "r");
83+
$err = new SplFileObject($errPath, "r");
84+
$out->rewind();
85+
$err->rewind();
86+
return [
87+
"out" => $out->fread(8192),
88+
"err" => $err->fread(8192),
89+
];
90+
}
91+
92+
public function testExecuteMigratesAll():void {
93+
$project = $this->createProjectDir();
94+
$sqlitePath = str_replace("\\", "/", $project . DIRECTORY_SEPARATOR . "cli-test.db");
95+
$this->writeConfigIni($project, $sqlitePath);
96+
$this->createMigrations($project, 3);
97+
98+
$cwdBackup = getcwd();
99+
chdir($project);
100+
try {
101+
$cmd = new ExecuteCommand();
102+
$streams = $this->makeStreamFiles();
103+
$cmd->setStream($streams["stream"]);
104+
105+
$args = new ArgumentValueList();
106+
// No additional params; simply run
107+
$cmd->run($args);
108+
109+
list("out" => $out) = $this->readFromFiles($streams["out"], $streams["err"]);
110+
self::assertStringContainsString("Migration 1:", $out);
111+
self::assertStringContainsString("3 migrations were completed successfully.", $out);
112+
113+
// Verify DB state
114+
$settings = new Settings($project . DIRECTORY_SEPARATOR . "query", Settings::DRIVER_SQLITE, $sqlitePath);
115+
$db = new Database($settings);
116+
$result = $db->executeSql("PRAGMA table_info(test);");
117+
self::assertGreaterThanOrEqual(4, count($result->fetchAll()));
118+
}
119+
finally {
120+
chdir($cwdBackup);
121+
}
122+
}
123+
124+
public function testExecuteWithResetWithoutNumber():void {
125+
$project = $this->createProjectDir();
126+
$sqlitePath = str_replace("\\", "/", $project . DIRECTORY_SEPARATOR . "cli-test.db");
127+
$this->writeConfigIni($project, $sqlitePath);
128+
$migrations = $this->createMigrations($project, 4);
129+
130+
// Prepare base state: create table so we can skip first migration safely.
131+
$settings = new Settings($project . DIRECTORY_SEPARATOR . "query", Settings::DRIVER_SQLITE, $sqlitePath);
132+
$db = new Database($settings);
133+
$db->executeSql(self::MIGRATION_CREATE);
134+
135+
$cwdBackup = getcwd();
136+
chdir($project);
137+
try {
138+
$cmd = new ExecuteCommand();
139+
$streams = $this->makeStreamFiles();
140+
$cmd->setStream($streams["stream"]);
141+
142+
$args = new ArgumentValueList();
143+
$args->set("reset"); // No number provided: should reset to latest migration number
144+
145+
$cmd->run($args);
146+
147+
list("out" => $out) = $this->readFromFiles($streams["out"], $streams["err"]);
148+
// Should only execute the last migration
149+
self::assertMatchesRegularExpression("/Migration\\s+4:/", $out);
150+
self::assertStringContainsString("1 migrations were completed successfully.", $out);
151+
}
152+
finally {
153+
chdir($cwdBackup);
154+
}
155+
}
156+
157+
public function testExecuteWithResetWithNumber():void {
158+
$project = $this->createProjectDir();
159+
$sqlitePath = str_replace("\\", "/", $project . DIRECTORY_SEPARATOR . "cli-test.db");
160+
$this->writeConfigIni($project, $sqlitePath);
161+
$migrations = $this->createMigrations($project, 5);
162+
163+
// Prepare base state when skipping the initial migrations.
164+
$settings = new Settings($project . DIRECTORY_SEPARATOR . "query", Settings::DRIVER_SQLITE, $sqlitePath);
165+
$db = new Database($settings);
166+
$db->executeSql(self::MIGRATION_CREATE);
167+
168+
$cwdBackup = getcwd();
169+
chdir($project);
170+
try {
171+
$cmd = new ExecuteCommand();
172+
$streams = $this->makeStreamFiles();
173+
$cmd->setStream($streams["stream"]);
174+
175+
$args = new ArgumentValueList();
176+
$args->set("reset", "3"); // Should migrate from 4 and 5 only
177+
178+
$cmd->run($args);
179+
180+
list("out" => $out) = $this->readFromFiles($streams["out"], $streams["err"]);
181+
self::assertStringNotContainsString("Migration 1:", $out);
182+
self::assertStringNotContainsString("Migration 2:", $out);
183+
self::assertStringNotContainsString("Migration 3:", $out);
184+
self::assertStringContainsString("Migration 4:", $out);
185+
self::assertStringContainsString("Migration 5:", $out);
186+
self::assertStringContainsString("2 migrations were completed successfully.", $out);
187+
}
188+
finally {
189+
chdir($cwdBackup);
190+
}
191+
}
192+
}

test/phpunit/Migration/MigratorTest.php

Lines changed: 122 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -114,8 +114,12 @@ public function testCheckFileListOrderMissing(array $fileList) {
114114
$settings = $this->createSettings($path);
115115
$migrator = new Migrator($settings, $path);
116116
$actualFileList = $migrator->getMigrationFileList();
117-
self::expectException(MigrationSequenceOrderException::class);
118-
$migrator->checkFileListOrder($actualFileList);
117+
$exception = null;
118+
try {
119+
$migrator->checkFileListOrder($actualFileList);
120+
}
121+
catch (Exception $exception) {}
122+
self::assertNull($exception, "No exception should be thrown for missing sequence numbers as long as order is increasing and non-duplicated");
119123
}
120124

121125
/** @dataProvider dataMigrationFileListDuplicate */
@@ -130,6 +134,29 @@ public function testCheckFileListOrderDuplicate(array $fileList) {
130134
$migrator->checkFileListOrder($actualFileList);
131135
}
132136

137+
public function testCheckFileListOrderOutOfOrder():void {
138+
$path = $this->getMigrationDirectory();
139+
$files = [
140+
str_pad(1, 4, "0", STR_PAD_LEFT) . "-" . uniqid() . ".sql",
141+
str_pad(2, 4, "0", STR_PAD_LEFT) . "-" . uniqid() . ".sql",
142+
str_pad(3, 4, "0", STR_PAD_LEFT) . "-" . uniqid() . ".sql",
143+
];
144+
$this->createFiles($files, $path);
145+
146+
$settings = $this->createSettings($path);
147+
$migrator = new Migrator($settings, $path);
148+
149+
$absolute = array_map(function($f) use ($path) {
150+
return implode(DIRECTORY_SEPARATOR, [$path, $f]);
151+
}, $files);
152+
153+
// Pass files deliberately out of numeric order: 1, 3, 2
154+
$outOfOrder = [$absolute[0], $absolute[2], $absolute[1]];
155+
156+
$this->expectException(MigrationSequenceOrderException::class);
157+
$migrator->checkFileListOrder($outOfOrder);
158+
}
159+
133160
/** @dataProvider dataMigrationFileList */
134161
public function testCheckIntegrityGood(array $fileList) {
135162
$path = $this->getMigrationDirectory();
@@ -794,6 +821,99 @@ protected function createSettings(string $path):Settings {
794821
);
795822
}
796823

824+
/**
825+
* New tests for migrating from a specific file number and handling gaps
826+
*/
827+
/** @dataProvider dataMigrationFileList */
828+
public function testPerformMigrationFromSpecificNumber(array $fileList) {
829+
$path = $this->getMigrationDirectory();
830+
$this->createMigrationFiles($fileList, $path);
831+
$settings = $this->createSettings($path);
832+
$migrator = new Migrator($settings, $path);
833+
834+
$absoluteFileList = array_map(function($file) use ($path) {
835+
return implode(DIRECTORY_SEPARATOR, [ $path, $file ]);
836+
}, $fileList);
837+
838+
$startNumber = $migrator->extractNumberFromFilename($absoluteFileList[0]);
839+
$from = $startNumber - 1;
840+
841+
$migrator->createMigrationTable();
842+
if($from >= 1) {
843+
// Ensure base table exists when skipping the first migration
844+
$db = new Database($settings);
845+
$db->executeSql(self::MIGRATION_CREATE);
846+
}
847+
848+
$streamOut = new SplFileObject("php://memory", "w");
849+
$migrator->setOutput($streamOut);
850+
851+
$executed = $migrator->performMigration($absoluteFileList, $from);
852+
853+
$expected = 0;
854+
foreach($absoluteFileList as $file) {
855+
if($migrator->extractNumberFromFilename($file) >= $startNumber) {
856+
$expected++;
857+
}
858+
}
859+
860+
$streamOut->rewind();
861+
$output = $streamOut->fread(4096);
862+
self::assertMatchesRegularExpression("/Migration\\s+{$startNumber}:/", $output);
863+
self::assertStringContainsString("$expected migrations were completed successfully.", $output);
864+
self::assertSame($expected, $executed);
865+
self::assertSame($expected, $migrator->getMigrationCount());
866+
}
867+
868+
/** @dataProvider dataMigrationFileListMissing */
869+
public function testPerformMigrationFromSpecificNumberWithGaps(array $fileList) {
870+
$path = $this->getMigrationDirectory();
871+
$this->createMigrationFiles($fileList, $path);
872+
$settings = $this->createSettings($path);
873+
$migrator = new Migrator($settings, $path);
874+
875+
$absoluteFileList = array_map(function($file) use ($path) {
876+
return implode(DIRECTORY_SEPARATOR, [ $path, $file ]);
877+
}, $fileList);
878+
879+
// Build the list of actual migration numbers present (with gaps allowed)
880+
$numbers = array_map(function($file) use ($migrator) {
881+
return $migrator->extractNumberFromFilename($file);
882+
}, $absoluteFileList);
883+
sort($numbers);
884+
885+
// Pick a start number from the set (not the last one)
886+
$startNumber = $numbers[(int)floor(count($numbers) / 2)];
887+
$from = $startNumber - 1;
888+
889+
$migrator->createMigrationTable();
890+
if($from >= 1) {
891+
$db = new Database($settings);
892+
$db->executeSql(self::MIGRATION_CREATE);
893+
}
894+
895+
$streamOut = new SplFileObject("php://memory", "w");
896+
$migrator->setOutput($streamOut);
897+
898+
$executed = $migrator->performMigration($absoluteFileList, $from);
899+
900+
$expected = 0;
901+
foreach($numbers as $n) {
902+
if($n >= $startNumber) {
903+
$expected++;
904+
}
905+
}
906+
907+
$streamOut->rewind();
908+
$output = $streamOut->fread(4096);
909+
self::assertMatchesRegularExpression("/Migration\\s+{$startNumber}:/", $output);
910+
for($n = 1; $n < $startNumber; $n++) {
911+
self::assertStringNotContainsString("Migration $n:", $output);
912+
}
913+
self::assertStringContainsString("$expected migrations were completed successfully.", $output);
914+
self::assertSame($expected, $executed);
915+
}
916+
797917
protected function createFiles(array $files, string $path):void {
798918
foreach($files as $filename) {
799919
$pathName = implode(DIRECTORY_SEPARATOR, [

0 commit comments

Comments
 (0)