Skip to content
Open
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
16 changes: 16 additions & 0 deletions README.md
Original file line number Diff line number Diff line change
Expand Up @@ -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`

Expand Down
88 changes: 85 additions & 3 deletions src/Command/MigrateCommand.php
Original file line number Diff line number Diff line change
@@ -1,14 +1,96 @@
<?php
namespace GT\GtCommand\Command;

use Closure;
use Gt\Cli\Argument\ArgumentValueList;
use Gt\Cli\Command\Command;
use Gt\Cli\Parameter\Parameter;
use Gt\Cli\Stream;
use GT\Database\Cli\ExecuteCommand as ExecuteMigrationCommand;
use Throwable;

class MigrateCommand extends AbstractProxyCommand {
public function __construct() {
$this->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"),
];
}
}
82 changes: 82 additions & 0 deletions src/Command/SqlMigrationDetector.php
Original file line number Diff line number Diff line change
@@ -0,0 +1,82 @@
<?php
namespace GT\GtCommand\Command;

use Gt\Cli\Argument\ArgumentValueList;
use Gt\Config\Config;
use Gt\Config\ConfigFactory;

class SqlMigrationDetector {
public function hasMigrations(
string $projectRoot,
?ArgumentValueList $arguments = null,
):bool {
$config = $this->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;
}
}
178 changes: 178 additions & 0 deletions test/phpunit/Command/MigrateCommandTest.php
Original file line number Diff line number Diff line change
@@ -0,0 +1,178 @@
<?php
namespace GT\GtCommand\Test\Command;

use Gt\Cli\Argument\ArgumentValueList;
use Gt\Cli\Command\Command;
use Gt\Cli\Parameter\NamedParameter;
use Gt\Cli\Parameter\Parameter;
use GT\GtCommand\Command\MigrateCommand;
use GT\GtCommand\Command\SqlMigrationDetector;
use PHPUnit\Framework\TestCase;
use RuntimeException;

class MigrateCommandTest extends TestCase {
private string $projectRoot;
private string $previousDirectory;

protected function setUp():void {
$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 {
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<string> */
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<NamedParameter> */
public function getRequiredNamedParameterList():array {
return [];
}

/** @return list<NamedParameter> */
public function getOptionalNamedParameterList():array {
return [];
}

/** @return list<Parameter> */
public function getRequiredParameterList():array {
return [];
}

/** @return list<Parameter> */
public function getOptionalParameterList():array {
return [new Parameter(false, "sql-option")];
}
}
Loading
Loading