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
4 changes: 2 additions & 2 deletions composer.json
Original file line number Diff line number Diff line change
Expand Up @@ -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",
Expand Down
19 changes: 19 additions & 0 deletions phpunit.xml
Original file line number Diff line number Diff line change
@@ -0,0 +1,19 @@
<?xml version="1.0"?>
<phpunit
xmlns:xsi="http://www.w3.org/2001/XMLSchema-instance"
xsi:noNamespaceSchemaLocation="https://schema.phpunit.de/13.3/phpunit.xsd"
colors="true"
cacheDirectory="test/phpunit/.phpunit.cache"
bootstrap="vendor/autoload.php"
>
<testsuites>
<testsuite name="main">
<directory>./test/phpunit/</directory>
</testsuite>
</testsuites>
<source>
<include>
<directory>src/</directory>
</include>
</source>
</phpunit>
109 changes: 106 additions & 3 deletions src/Command/MigrateCommand.php
Original file line number Diff line number Diff line change
@@ -1,14 +1,117 @@
<?php
namespace GT\GtCommand\Command;

use Closure;
use Composer\Autoload\ClassLoader;
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;
}
}

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"),
];
}
}
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;
}
}
Loading
Loading