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/composer.json b/composer.json
index d477d80..7de392d 100644
--- a/composer.json
+++ b/composer.json
@@ -8,12 +8,12 @@
"config": {
"platform": {
- "php": "8.3.0"
+ "php": "8.4.0"
}
},
"require": {
- "php": ">=8.3",
+ "php": ">=8.4",
"phpgt/cli": "^1.3",
"phpgt/server": "^1.2",
"phpgt/cron": "^1.0",
diff --git a/phpunit.xml b/phpunit.xml
new file mode 100644
index 0000000..71c23e0
--- /dev/null
+++ b/phpunit.xml
@@ -0,0 +1,19 @@
+
+
+
+
+ ./test/phpunit/
+
+
+
+
+ src/
+
+
+
diff --git a/src/Command/MigrateCommand.php b/src/Command/MigrateCommand.php
index 4f59a68..3ef9074 100644
--- a/src/Command/MigrateCommand.php
+++ b/src/Command/MigrateCommand.php
@@ -1,14 +1,117 @@
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;
+ }
+ }
+
+ return $this->runOrm($projectRoot, $arguments);
+ }
+ catch(Throwable $exception) {
+ $this->output(
+ "Migration failed: " . $exception->getMessage(),
+ streamName: Stream::ERROR,
+ );
+ return 1;
+ }
+ }
+
+ private function runOrm(string $projectRoot, ?ArgumentValueList $arguments):int {
+ if($arguments?->contains("no-orm")) {
+ return 0;
+ }
+ if(!$this->projectHasOrm($projectRoot)) {
+ return 0;
+ }
+ $ormCommand = ($this->ormCommandFactory)();
+ if($ormCommand === null) {
+ return 0;
+ }
+ $ormCommand->setStream($this->stream ?? null);
+ return $ormCommand->run($arguments);
+ }
+
+ private function projectHasOrm(string $projectRoot):bool {
+ $autoload = "$projectRoot/vendor/autoload.php";
+ if(!is_file($autoload)) {
+ return false;
+ }
+
+ // Composer returns the loader even if it has already been registered.
+ // Ask this loader directly: class_exists() also searches global packages.
+ $loader = require $autoload;
+ return $loader instanceof ClassLoader
+ && $loader->findFile("GT\\Orm\\Cli\\MigrateCommand") !== false;
}
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..f028e4f
--- /dev/null
+++ b/test/phpunit/Command/MigrateCommandTest.php
@@ -0,0 +1,253 @@
+ */
+ private array $previousAutoloaders;
+
+ protected function setUp():void {
+ $this->previousAutoloaders = spl_autoload_functions();
+ $this->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 {
+ foreach(spl_autoload_functions() as $autoload) {
+ if(!in_array($autoload, $this->previousAutoloaders, true)) {
+ spl_autoload_unregister($autoload);
+ }
+ }
+ chdir($this->previousDirectory);
+ }
+
+ public function testGloballyAvailableOrmDoesNotEnableSqlOnlyProject():void {
+ $this->createSqlMigration();
+ $this->createProjectAutoloader(false);
+ $globalLoader = new ClassLoader();
+ $globalLoader->addClassMap(["GT\\Orm\\Cli\\MigrateCommand" => __FILE__]);
+ $globalLoader->register();
+ $sql = new RecordingCommand("sql");
+ $command = new MigrateCommand($sql, ormCommandFactory: static function():?Command {
+ self::fail("A globally available ORM must not enable project migrations.");
+ });
+
+ self::assertSame(0, $command->run());
+ self::assertSame(1, $sql->runCount);
+ }
+
+ public function testMissingProjectAutoloaderSkipsOrmFactory():void {
+ $command = new MigrateCommand(ormCommandFactory: static function():?Command {
+ self::fail("ORM must not run without a project autoloader.");
+ });
+
+ self::assertSame(0, $command->run());
+ }
+
+ public function testProjectAutoloaderIsRegisteredBeforeOrmFactoryAndCanBeLoadedAgain():void {
+ $this->createProjectAutoloader();
+ $loader = require $this->projectRoot . "/vendor/autoload.php";
+ $orm = new RecordingCommand("orm");
+ $command = new MigrateCommand(ormCommandFactory: static function() use ($loader, $orm):Command {
+ self::assertContains([$loader, "loadClass"], spl_autoload_functions());
+ return $orm;
+ });
+
+ self::assertSame(0, $command->run());
+ self::assertSame(1, $orm->runCount);
+ }
+
+ public function testProjectClassesCanBeAutoloadedDuringOrmDiscovery():void {
+ $this->createProjectAutoloader();
+ $className = "MigrationProjectFixture" . uniqid();
+ file_put_contents($this->projectRoot . "/vendor/$className.php", "run());
+ self::assertSame(1, $orm->runCount);
+ }
+
+ 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 {
+ if($orm !== null) {
+ $this->createProjectAutoloader();
+ }
+ return new MigrateCommand(
+ $sql,
+ new SqlMigrationDetector(),
+ static fn():?Command => $orm,
+ );
+ }
+
+ private function createProjectAutoloader(bool $withOrm = true):void {
+ $directory = $this->projectRoot . "/vendor";
+ mkdir($directory, recursive: true);
+ $classMap = $withOrm ? ["GT\\Orm\\Cli\\MigrateCommand" => __FILE__] : [];
+ file_put_contents($directory . "/autoload.php", "addPsr4("", __DIR__);' . "\n"
+ . '$loader->addClassMap(' . var_export($classMap, true) . ");\n"
+ . '$loader->register();' . "\n"
+ . 'return $loader;' . "\n");
+ }
+
+ 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));
+ }
+}