diff --git a/README.md b/README.md index 0c82b92..a0992eb 100644 --- a/README.md +++ b/README.md @@ -14,6 +14,22 @@ The following commands are exposed: + `gt cron` - invoke scripts or static functions at regular intervals + `gt run` - run all background scripts at once - a combination of `serve`, `build --watch` and `cron --watch --now` + `gt deploy` - instantly deploy your application to the internet ++ `gt migrate` - apply optional SQL-file and ORM Entity migrations + +## `gt migrate` + +`gt migrate` supports two independent migration styles. Numbered SQL files in +`query/_migration` are applied when that directory contains migrations. When +`phpgt/orm` is installed, Entity classes in `app.class_dir` are also compared +with the latest schema recorded in the ORM's separate `_orm` table. + +A project may use SQL migrations, ORM migrations, both, or neither. SQL files +run first when both styles are present. If they fail, ORM migration does not +run. Useful ORM options are: + ++ `--no-orm` - skip Entity migrations ++ `--orm-plan` - show the Entity schema changes without applying them ++ `--orm-baseline` - record an existing matching schema without changing tables ## `gt add` diff --git a/src/Command/MigrateCommand.php b/src/Command/MigrateCommand.php index 4f59a68..11c62eb 100644 --- a/src/Command/MigrateCommand.php +++ b/src/Command/MigrateCommand.php @@ -1,14 +1,96 @@ proxyCommand = new ExecuteMigrationCommand(); +class MigrateCommand extends Command { + /** @var Closure():?Command */ + private Closure $ormCommandFactory; + + /** @param null|Closure():?Command $ormCommandFactory */ + public function __construct( + private readonly Command $sqlCommand = new ExecuteMigrationCommand(), + private readonly SqlMigrationDetector $sqlMigrationDetector = new SqlMigrationDetector(), + ?Closure $ormCommandFactory = null, + ) { + $this->ormCommandFactory = $ormCommandFactory + ?? static function():?Command { + $className = "GT\\Orm\\Cli\\MigrateCommand"; + if(!class_exists($className) + || !is_a($className, Command::class, true)) { + return null; + } + return new $className(); + }; + } + + public function run(?ArgumentValueList $arguments = null):int { + $projectRoot = getcwd(); + if($projectRoot === false) { + $this->output("Unable to determine the project directory.", streamName: Stream::ERROR); + return 1; + } + + try { + if($this->sqlMigrationDetector->hasMigrations($projectRoot, $arguments)) { + $this->sqlCommand->setStream($this->stream ?? null); + $status = $this->sqlCommand->run($arguments); + if($status !== 0) { + return $status; + } + } + + if($arguments?->contains("no-orm")) { + return 0; + } + $ormCommand = ($this->ormCommandFactory)(); + if($ormCommand === null) { + return 0; + } + $ormCommand->setStream($this->stream ?? null); + return $ormCommand->run($arguments); + } + catch(Throwable $exception) { + $this->output( + "Migration failed: " . $exception->getMessage(), + streamName: Stream::ERROR, + ); + return 1; + } } public function getName():string { return "migrate"; } + + public function getDescription():string { + return "Perform SQL-file and ORM Entity migrations"; + } + + public function getRequiredNamedParameterList():array { + return $this->sqlCommand->getRequiredNamedParameterList(); + } + + public function getOptionalNamedParameterList():array { + return $this->sqlCommand->getOptionalNamedParameterList(); + } + + public function getRequiredParameterList():array { + return $this->sqlCommand->getRequiredParameterList(); + } + + public function getOptionalParameterList():array { + return [ + ...$this->sqlCommand->getOptionalParameterList(), + new Parameter(false, "no-orm", null, "Skip ORM Entity migrations"), + new Parameter(false, "orm-baseline", null, "Record the current Entity schema without changing tables"), + new Parameter(false, "orm-plan", null, "Display ORM changes without applying them"), + ]; + } } diff --git a/src/Command/SqlMigrationDetector.php b/src/Command/SqlMigrationDetector.php new file mode 100644 index 0000000..15e88d3 --- /dev/null +++ b/src/Command/SqlMigrationDetector.php @@ -0,0 +1,82 @@ +loadConfig($projectRoot); + $queryPath = $arguments?->contains("base-directory") + ? $arguments->get("base-directory")->get() + : $config->get("database.query_path"); + $queryPath ??= "query"; + $migrationPath = $config->get("database.migration_path") ?? "_migration"; + $directory = $this->resolvePath($projectRoot, $queryPath) + . DIRECTORY_SEPARATOR . $migrationPath; + + if($this->containsNumberedSqlFile($directory)) { + return true; + } + if(!$arguments?->contains("dev") + && !$arguments?->contains("dev-merge")) { + return false; + } + + $devPath = $config->get("database.dev_migration_path") + ?? "_migration" . DIRECTORY_SEPARATOR . "dev"; + $devDirectory = $this->resolvePath($projectRoot, $queryPath) + . DIRECTORY_SEPARATOR . $devPath; + return $this->containsNumberedSqlFile($devDirectory); + } + + /** @SuppressWarnings("PHPMD.StaticAccess") */ + private function loadConfig(string $projectRoot):Config { + $defaultPath = $this->findDefaultConfig($projectRoot); + if($defaultPath === null && !$this->hasProjectConfig($projectRoot)) { + return new Config(); + } + return ConfigFactory::createForProject($projectRoot, $defaultPath); + } + + private function findDefaultConfig(string $projectRoot):?string { + $directory = $this->resolvePath($projectRoot, "vendor/phpgt/webengine"); + foreach(["config.default.ini", "default.ini"] as $fileName) { + $path = "$directory/$fileName"; + if(is_file($path)) { + return $path; + } + } + return null; + } + + private function hasProjectConfig(string $projectRoot):bool { + foreach(["config.default.ini", "config.ini", "config.dev.ini", "config.deploy.ini", "config.production.ini"] as $fileName) { + if(is_file($this->resolvePath($projectRoot, $fileName))) { + return true; + } + } + return false; + } + + private function containsNumberedSqlFile(string $directory):bool { + $fileList = glob("$directory/*.sql") ?: []; + foreach($fileList as $file) { + if(preg_match("/^\\d+.*\\.sql$/", basename($file)) === 1) { + return true; + } + } + return false; + } + + private function resolvePath(string $projectRoot, string $path):string { + if(str_starts_with($path, DIRECTORY_SEPARATOR)) { + return $path; + } + return $projectRoot . DIRECTORY_SEPARATOR . $path; + } +} diff --git a/test/phpunit/Command/MigrateCommandTest.php b/test/phpunit/Command/MigrateCommandTest.php new file mode 100644 index 0000000..835a5c0 --- /dev/null +++ b/test/phpunit/Command/MigrateCommandTest.php @@ -0,0 +1,178 @@ +projectRoot = sys_get_temp_dir() . "/phpgt-migrate-command-" . uniqid(); + mkdir($this->projectRoot, recursive: true); + $this->previousDirectory = getcwd() ?: __DIR__; + chdir($this->projectRoot); + } + + protected function tearDown():void { + chdir($this->previousDirectory); + } + + public function testNeitherMigrationStyleIsANoOp():void { + $sql = new RecordingCommand("sql"); + $command = $this->command($sql, null); + + self::assertSame(0, $command->run(new ArgumentValueList())); + self::assertSame(0, $sql->runCount); + } + + public function testSqlOnlyRunsSqlPhase():void { + $this->createSqlMigration(); + $sql = new RecordingCommand("sql"); + + self::assertSame(0, $this->command($sql, null)->run(new ArgumentValueList())); + self::assertSame(1, $sql->runCount); + } + + public function testOrmOnlyRunsOrmPhase():void { + $sql = new RecordingCommand("sql"); + $orm = new RecordingCommand("orm"); + + self::assertSame(0, $this->command($sql, $orm)->run(new ArgumentValueList())); + self::assertSame(0, $sql->runCount); + self::assertSame(1, $orm->runCount); + } + + public function testNoOrmOptionSkipsOrmPhase():void { + $orm = new RecordingCommand("orm"); + $arguments = new ArgumentValueList(); + $arguments->set("no-orm"); + + self::assertSame(0, $this->command(new RecordingCommand("sql"), $orm)->run($arguments)); + self::assertSame(0, $orm->runCount); + } + + public function testSqlRunsBeforeOrmWhenBothArePresent():void { + $this->createSqlMigration(); + $log = new MigrationCallLog(); + $sql = new RecordingCommand("sql", $log); + $orm = new RecordingCommand("orm", $log); + + self::assertSame(0, $this->command($sql, $orm)->run(new ArgumentValueList())); + self::assertSame(["sql", "orm"], $log->calls); + } + + public function testSqlFailureStopsOrmAndIsPropagated():void { + $this->createSqlMigration(); + $sql = new RecordingCommand("sql", status: 7); + $orm = new RecordingCommand("orm"); + + self::assertSame(7, $this->command($sql, $orm)->run(new ArgumentValueList())); + self::assertSame(0, $orm->runCount); + } + + public function testOrmFailureIsPropagated():void { + $orm = new RecordingCommand("orm", status: 2); + + self::assertSame(2, $this->command(new RecordingCommand("sql"), $orm)->run(new ArgumentValueList())); + self::assertSame(1, $orm->runCount); + } + + public function testThrownFailureReturnsNonZeroAndStopsOrm():void { + $this->createSqlMigration(); + $sql = new RecordingCommand("sql", exception: new RuntimeException("broken")); + $orm = new RecordingCommand("orm"); + + self::assertSame(1, $this->command($sql, $orm)->run(new ArgumentValueList())); + self::assertSame(0, $orm->runCount); + } + + public function testOrmOptionsAreAddedToSqlOptions():void { + $command = $this->command(new RecordingCommand("sql"), null); + $optionNames = array_map( + fn(Parameter $parameter):string => $parameter->getLongOption(), + $command->getOptionalParameterList(), + ); + + self::assertContains("sql-option", $optionNames); + self::assertContains("no-orm", $optionNames); + self::assertContains("orm-baseline", $optionNames); + self::assertContains("orm-plan", $optionNames); + } + + private function command(RecordingCommand $sql, ?RecordingCommand $orm):MigrateCommand { + return new MigrateCommand( + $sql, + new SqlMigrationDetector(), + static fn():?Command => $orm, + ); + } + + private function createSqlMigration():void { + $directory = $this->projectRoot . "/query/_migration"; + mkdir($directory, recursive: true); + file_put_contents($directory . "/001.sql", "select 1"); + } +} + +class MigrationCallLog { + /** @var list */ + public array $calls = []; +} + +class RecordingCommand extends Command { + public int $runCount = 0; + + public function __construct( + private readonly string $name, + private readonly ?MigrationCallLog $log = null, + private readonly int $status = 0, + private readonly ?RuntimeException $exception = null, + ) {} + + public function run(?ArgumentValueList $arguments = null):int { + $this->runCount++; + if($this->log !== null) { + $this->log->calls[] = $this->name; + } + if($this->exception !== null) { + throw $this->exception; + } + return $this->status; + } + + public function getName():string { + return $this->name; + } + + public function getDescription():string { + return $this->name; + } + + /** @return list */ + public function getRequiredNamedParameterList():array { + return []; + } + + /** @return list */ + public function getOptionalNamedParameterList():array { + return []; + } + + /** @return list */ + public function getRequiredParameterList():array { + return []; + } + + /** @return list */ + public function getOptionalParameterList():array { + return [new Parameter(false, "sql-option")]; + } +} diff --git a/test/phpunit/Command/SqlMigrationDetectorTest.php b/test/phpunit/Command/SqlMigrationDetectorTest.php new file mode 100644 index 0000000..f6ae09f --- /dev/null +++ b/test/phpunit/Command/SqlMigrationDetectorTest.php @@ -0,0 +1,58 @@ +projectRoot = sys_get_temp_dir() . "/phpgt-migration-detector-" . uniqid(); + mkdir($this->projectRoot, recursive: true); + } + + public function testProjectWithoutMigrationDirectoryHasNoMigrations():void { + self::assertFalse((new SqlMigrationDetector())->hasMigrations($this->projectRoot)); + } + + public function testOnlyNumberedSqlFilesCountAsMigrations():void { + $directory = $this->projectRoot . "/query/_migration"; + mkdir($directory, recursive: true); + file_put_contents($directory . "/notes.sql", "select 1"); + self::assertFalse((new SqlMigrationDetector())->hasMigrations($this->projectRoot)); + + file_put_contents($directory . "/001-create.sql", "select 1"); + self::assertTrue((new SqlMigrationDetector())->hasMigrations($this->projectRoot)); + } + + public function testConfiguredAndOverriddenQueryPathsAreUsed():void { + file_put_contents($this->projectRoot . "/config.ini", <<projectRoot . "/database-query/changes"; + mkdir($directory, recursive: true); + file_put_contents($directory . "/001.sql", "select 1"); + $detector = new SqlMigrationDetector(); + self::assertTrue($detector->hasMigrations($this->projectRoot)); + + $arguments = new ArgumentValueList(); + $arguments->set("base-directory", "other-query"); + self::assertFalse($detector->hasMigrations($this->projectRoot, $arguments)); + } + + public function testDevMigrationsOnlyCountWhenRequested():void { + $directory = $this->projectRoot . "/query/_migration/dev"; + mkdir($directory, recursive: true); + file_put_contents($directory . "/001-dev.sql", "select 1"); + $detector = new SqlMigrationDetector(); + self::assertFalse($detector->hasMigrations($this->projectRoot)); + + $arguments = new ArgumentValueList(); + $arguments->set("dev"); + self::assertTrue($detector->hasMigrations($this->projectRoot, $arguments)); + } +}