From 4cbde826774f2a533278e4d2b98f523c65fa42d7 Mon Sep 17 00:00:00 2001 From: Ibrahim BinAlshikh Date: Wed, 13 May 2026 11:50:56 +0300 Subject: [PATCH 1/2] feat(cli): add migrations:skip command for baselining Add a CLI command to mark migrations as applied without executing them. Supports three modes: - --name: skip a single migration by class name - --all: skip all pending migrations (full baseline) - --up-to: skip all up to and including a named migration Closes #320 --- WebFiori/Framework/App.php | 1 + .../Cli/Commands/SkipMigrationsCommand.php | 164 +++++++++ .../Framework/Tests/Cli/HelpCommandTest.php | 1 + .../Tests/Cli/SkipMigrationsCommandTest.php | 342 ++++++++++++++++++ 4 files changed, 508 insertions(+) create mode 100644 WebFiori/Framework/Cli/Commands/SkipMigrationsCommand.php create mode 100644 tests/WebFiori/Framework/Tests/Cli/SkipMigrationsCommandTest.php diff --git a/WebFiori/Framework/App.php b/WebFiori/Framework/App.php index 036ec32ab..ad15866c8 100644 --- a/WebFiori/Framework/App.php +++ b/WebFiori/Framework/App.php @@ -377,6 +377,7 @@ public static function getRunner() : Runner { '\\WebFiori\\Framework\\Cli\\Commands\\DryRunMigrationsCommand', '\\WebFiori\\Framework\\Cli\\Commands\\MigrationsStatusCommand', '\\WebFiori\\Framework\\Cli\\Commands\\FreshMigrationsCommand', + '\\WebFiori\\Framework\\Cli\\Commands\\SkipMigrationsCommand', ]; foreach ($commands as $c) { diff --git a/WebFiori/Framework/Cli/Commands/SkipMigrationsCommand.php b/WebFiori/Framework/Cli/Commands/SkipMigrationsCommand.php new file mode 100644 index 000000000..79b9c7bcb --- /dev/null +++ b/WebFiori/Framework/Cli/Commands/SkipMigrationsCommand.php @@ -0,0 +1,164 @@ +getConnection(); + if ($connection === null) { + return 1; + } + + $env = $this->getArgValue('--env') ?? 'dev'; + $this->runner = new SchemaRunner($connection, $env); + + // Discover migrations + $migrationsPath = APP_PATH.'Database'.DS.'Migrations'; + $namespace = APP_DIR.'\\Database\\Migrations'; + $count = $this->runner->discoverFromPath($migrationsPath, $namespace, true); + + $seedersPath = APP_PATH.'Database'.DS.'Seeders'; + $seedersNamespace = APP_DIR.'\\Database\\Seeders'; + $count += $this->runner->discoverFromPath($seedersPath, $seedersNamespace, true); + + if ($count === 0) { + $this->info('No migrations found.'); + return 0; + } + + return $this->skip(); + + } catch (Throwable $e) { + $msg = $e->getMessage(); + if (str_contains($msg, ".schema_changes' doesn't exist") && $e->getCode() == 1146) { + $this->warning('Table "schema_changes" does not exist.'); + $this->info('Run "migrations:ini" to create the table.'); + return 1; + } + $this->error('An exception was thrown.'); + $this->println('Message: ' . $e->getMessage()); + $this->println('File: ' . $e->getFile() . ':' . $e->getLine()); + return 1; + } finally { + if ($this->runner !== null) { + $this->runner->close(); + } + } + } + + private function getConnection(): ?ConnectionInfo { + $connections = App::getConfig()->getDBConnections(); + + if (empty($connections)) { + $this->info('No database connections configured.'); + return null; + } + + $connectionName = $this->getArgValue('--connection'); + + if ($connectionName !== null) { + $connection = App::getConfig()->getDBConnection($connectionName); + if ($connection === null) { + $this->error("Connection '$connectionName' not found."); + return null; + } + return $connection; + } + + return CLIUtils::getConnectionName($this); + } + + private function skip(): int { + if ($this->isArgProvided('--all')) { + return $this->skipAll(); + } else if ($this->isArgProvided('--name')) { + return $this->skipSingle(); + } else if ($this->isArgProvided('--up-to')) { + return $this->skipUpTo(); + } + + $this->error('Provide --name, --all, or --up-to.'); + return 1; + } + + private function skipAll(): int { + $skipped = $this->runner->skipAll(); + + if (empty($skipped)) { + $this->info('No pending migrations to skip.'); + return 0; + } + + foreach ($skipped as $change) { + $this->success('Skipped: ' . $change->getName()); + } + $this->info('Total skipped: ' . count($skipped)); + + return 0; + } + + private function skipSingle(): int { + $name = $this->getArgValue('--name'); + $result = $this->runner->skip($name); + + if ($result) { + $this->success('Skipped: ' . $name); + return 0; + } + + $this->warning('Could not skip: ' . $name . ' (not found or already applied)'); + return 1; + } + + private function skipUpTo(): int { + $name = $this->getArgValue('--up-to'); + $skipped = $this->runner->skipUpTo($name); + + if (empty($skipped)) { + $this->info('No pending migrations to skip.'); + return 0; + } + + foreach ($skipped as $change) { + $this->success('Skipped: ' . $change->getName()); + } + $this->info('Total skipped: ' . count($skipped)); + + return 0; + } +} diff --git a/tests/WebFiori/Framework/Tests/Cli/HelpCommandTest.php b/tests/WebFiori/Framework/Tests/Cli/HelpCommandTest.php index 16e7abc43..f34a2a4b0 100644 --- a/tests/WebFiori/Framework/Tests/Cli/HelpCommandTest.php +++ b/tests/WebFiori/Framework/Tests/Cli/HelpCommandTest.php @@ -46,6 +46,7 @@ public function test00() { " migrations:dry-run: Preview pending migrations without executing.\n", " migrations:status: Show migration status (applied and pending).\n", " migrations:fresh: Rollback all migrations and run them fresh.\n", + " migrations:skip: Mark migrations as applied without executing them (baseline).\n", ], $this->executeMultiCommand([ 'help', ])); diff --git a/tests/WebFiori/Framework/Tests/Cli/SkipMigrationsCommandTest.php b/tests/WebFiori/Framework/Tests/Cli/SkipMigrationsCommandTest.php new file mode 100644 index 000000000..742bc8af7 --- /dev/null +++ b/tests/WebFiori/Framework/Tests/Cli/SkipMigrationsCommandTest.php @@ -0,0 +1,342 @@ +removeAllDBConnections(); + + $output = $this->executeMultiCommand([ + SkipMigrationsCommand::class, + '--all' => '' + ]); + + $this->assertEquals([ + "Info: No database connections configured.\n" + ], $output); + $this->assertEquals(1, $this->getExitCode()); + } + + /** + * @test + */ + public function testSkipWithInvalidConnection() { + $output = $this->executeMultiCommand([ + SkipMigrationsCommand::class, + '--all' => '', + '--connection' => 'ghost' + ]); + + $this->assertEquals([ + "Error: Connection 'ghost' not found.\n" + ], $output); + $this->assertEquals(1, $this->getExitCode()); + } + + /** + * @test + */ + public function testSkipWithNoMigrations() { + $output = $this->executeMultiCommand([ + SkipMigrationsCommand::class, + '--all' => '', + '--connection' => 'test-connection' + ]); + + $this->assertEquals([ + "Info: No migrations found.\n" + ], $output); + $this->assertEquals(0, $this->getExitCode()); + } + + /** + * @test + */ + public function testSkipWithNoModeFlag() { + $this->createTestMigration('NoMode1'); + $this->initMigrations(); + + $output = $this->executeMultiCommand([ + SkipMigrationsCommand::class, + '--connection' => 'test-connection' + ]); + + $outputStr = implode('', $output); + $this->assertStringContainsString('Provide --name, --all, or --up-to', $outputStr); + $this->assertEquals(1, $this->getExitCode()); + } + + /** + * @test + */ + public function testSkipAll() { + $this->createTestMigration('SkipAll1'); + $this->createTestMigration('SkipAll2'); + $this->initMigrations(); + + $output = $this->executeMultiCommand([ + SkipMigrationsCommand::class, + '--all' => '', + '--connection' => 'test-connection' + ]); + + $outputStr = implode('', $output); + $this->assertStringContainsString('Skipped: App\\Database\\Migrations\\SkipAll1', $outputStr); + $this->assertStringContainsString('Skipped: App\\Database\\Migrations\\SkipAll2', $outputStr); + $this->assertStringContainsString('Total skipped: 2', $outputStr); + $this->assertEquals(0, $this->getExitCode()); + } + + /** + * @test + */ + public function testSkipSingleByName() { + $this->createTestMigration('SkipSingle1'); + $this->createTestMigration('SkipSingle2'); + $this->initMigrations(); + + $output = $this->executeMultiCommand([ + SkipMigrationsCommand::class, + '--name' => 'App\\Database\\Migrations\\SkipSingle1', + '--connection' => 'test-connection' + ]); + + $outputStr = implode('', $output); + $this->assertStringContainsString('Skipped: App\\Database\\Migrations\\SkipSingle1', $outputStr); + $this->assertEquals(0, $this->getExitCode()); + } + + /** + * @test + */ + public function testSkipUpTo() { + $this->createTestMigration('UpToMig'); + $this->initMigrations(); + + $output = $this->executeMultiCommand([ + SkipMigrationsCommand::class, + '--up-to' => 'UpToMig', + '--connection' => 'test-connection' + ]); + + $outputStr = implode('', $output); + $this->assertStringContainsString('Skipped: App\\Database\\Migrations\\UpToMig', $outputStr); + $this->assertStringContainsString('Total skipped:', $outputStr); + $this->assertEquals(0, $this->getExitCode()); + } + + /** + * @test + */ + public function testSkipAlreadyApplied() { + $this->createTestMigration('AlreadyDone'); + $this->initMigrations(); + + // Run it first + $this->executeMultiCommand([ + RunMigrationsCommandNew::class, + '--connection' => 'test-connection' + ]); + + // Try to skip it + $output = $this->executeMultiCommand([ + SkipMigrationsCommand::class, + '--name' => 'App\\Database\\Migrations\\AlreadyDone', + '--connection' => 'test-connection' + ]); + + $outputStr = implode('', $output); + $this->assertStringContainsString('Could not skip', $outputStr); + $this->assertStringContainsString('not found or already applied', $outputStr); + $this->assertEquals(1, $this->getExitCode()); + } + + /** + * @test + */ + public function testSkipAllWhenNothingPending() { + $this->createTestMigration('NoPending'); + $this->initMigrations(); + + // Run it first + $this->executeMultiCommand([ + RunMigrationsCommandNew::class, + '--connection' => 'test-connection' + ]); + + // Skip all - nothing left + $output = $this->executeMultiCommand([ + SkipMigrationsCommand::class, + '--all' => '', + '--connection' => 'test-connection' + ]); + + $outputStr = implode('', $output); + $this->assertStringContainsString('No pending migrations to skip', $outputStr); + $this->assertEquals(0, $this->getExitCode()); + } + + /** + * @test + */ + public function testSkipSchemaTableMissing() { + $this->createTestMigration('NoTable'); + // Do NOT call initMigrations() + + $output = $this->executeMultiCommand([ + SkipMigrationsCommand::class, + '--all' => '', + '--connection' => 'test-connection' + ]); + + $outputStr = implode('', $output); + $this->assertStringContainsString('schema_changes', $outputStr); + $this->assertStringContainsString('migrations:ini', $outputStr); + $this->assertEquals(1, $this->getExitCode()); + } + + /** + * @test + */ + public function testSkippedMigrationWontRun() { + $this->createTestMigration('WontRun'); + $this->initMigrations(); + + // Skip it + $this->executeMultiCommand([ + SkipMigrationsCommand::class, + '--name' => 'App\\Database\\Migrations\\WontRun', + '--connection' => 'test-connection' + ]); + + // Now run migrations + $output = $this->executeMultiCommand([ + RunMigrationsCommandNew::class, + '--connection' => 'test-connection' + ]); + + $outputStr = implode('', $output); + $this->assertStringContainsString('Skipped: App\\Database\\Migrations\\WontRun', $outputStr); + $this->assertStringNotContainsString('Applied: App\\Database\\Migrations\\WontRun', $outputStr); + $this->assertEquals(0, $this->getExitCode()); + } + + // --- Helpers --- + + private function initMigrations(string $env = 'dev'): void { + $args = [ + InitMigrationsCommand::class, + '--connection' => 'test-connection' + ]; + + if ($env !== 'dev') { + $args['--env'] = $env; + } + + $this->executeMultiCommand($args); + } + + private function createTestMigration(string $name): void { + $dir = APP_PATH.'Database'.DS.'Migrations'; + + if (!is_dir($dir)) { + mkdir($dir, 0755, true); + } + + $content = <<cleanPhpFiles($dir); + } + + private function cleanPhpFiles(string $dir): void { + if (!is_dir($dir)) { + return; + } + + $iterator = new \RecursiveIteratorIterator( + new \RecursiveDirectoryIterator($dir, \RecursiveDirectoryIterator::SKIP_DOTS), + \RecursiveIteratorIterator::CHILD_FIRST + ); + + foreach ($iterator as $item) { + if ($item->isFile() && $item->getExtension() === 'php') { + unlink($item->getRealPath()); + } elseif ($item->isDir() && count(scandir($item->getRealPath())) === 2) { + rmdir($item->getRealPath()); + } + } + } + + private function cleanupSeeders(): void { + $dir = APP_PATH.'Database'.DS.'Seeders'; + $this->cleanPhpFiles($dir); + } + + private function dropSchemaTable(): void { + try { + $conn = App::getConfig()->getDBConnection('test-connection'); + if ($conn !== null) { + $db = new \WebFiori\Database\Database($conn); + $db->raw("DROP TABLE IF EXISTS schema_changes")->execute(); + $db->close(); + } + } catch (\Throwable $e) { + // Ignore + } + } + + private function setupTestConnection(): void { + $this->testConnection = new ConnectionInfo('mysql', 'root', MYSQL_ROOT_PASSWORD, 'testing_db', '127.0.0.1', 3306); + $this->testConnection->setName('test-connection'); + App::getConfig()->addOrUpdateDBConnection($this->testConnection); + } + + protected function setUp(): void { + parent::setUp(); + $this->setupTestConnection(); + $this->dropSchemaTable(); + $this->cleanupMigrations(); + } + + protected function tearDown(): void { + $this->cleanupMigrations(); + $this->cleanupSeeders(); + $this->dropSchemaTable(); + parent::tearDown(); + } +} From 5a4daea0bed464f43980c273ff5d8f9b2f7967db Mon Sep 17 00:00:00 2001 From: Ibrahim BinAlshikh Date: Wed, 13 May 2026 12:15:26 +0300 Subject: [PATCH 2/2] feat(cli): add --all-connections flag and connection validation for migrations Add support for running migrations against all registered connections in a single command invocation. Also validates that migration target connection names exist in the registry and warns about default names. Changes: - Add --all-connections flag to migrations:run - Add validateTargetConnections() for typo/misconfiguration detection - Fix JsonDriver::getDBConnection() using wrong variable for connection-name Closes #326 --- .../Cli/Commands/RunMigrationsCommandNew.php | 174 +- .../Cli/Commands/SkipMigrationsCommand.php | 83 +- WebFiori/Framework/Config/JsonDriver.php | 1627 +++++++++-------- .../Cli/ConnectionTargetedMigrationsTest.php | 326 ++++ .../Tests/Cli/SkipMigrationsCommandTest.php | 251 +-- .../Framework/Tests/Config/JsonDriverTest.php | 6 +- 6 files changed, 1462 insertions(+), 1005 deletions(-) create mode 100644 tests/WebFiori/Framework/Tests/Cli/ConnectionTargetedMigrationsTest.php diff --git a/WebFiori/Framework/Cli/Commands/RunMigrationsCommandNew.php b/WebFiori/Framework/Cli/Commands/RunMigrationsCommandNew.php index b0f4943fc..c63167b6c 100644 --- a/WebFiori/Framework/Cli/Commands/RunMigrationsCommandNew.php +++ b/WebFiori/Framework/Cli/Commands/RunMigrationsCommandNew.php @@ -1,4 +1,5 @@ isArgProvided('--all-connections') && $this->isArgProvided('--connection')) { + $this->error('Cannot use --all-connections and --connection together.'); + + return 1; + } + + if ($this->isArgProvided('--all-connections')) { + return $this->runAllConnections(); + } + try { $connection = $this->getConnection(); + if ($connection === null) { return 1; } - + $env = $this->getArgValue('--env') ?? 'dev'; $this->runner = new SchemaRunner($connection, $env); - + // Discover migrations $migrationsPath = APP_PATH.'Database'.DS.'Migrations'; $namespace = APP_DIR.'\\Database\\Migrations'; @@ -52,24 +64,29 @@ public function exec(): int { $seedersPath = APP_PATH.'Database'.DS.'Seeders'; $seedersNamespace = APP_DIR.'\\Database\\Seeders'; $count += $this->runner->discoverFromPath($seedersPath, $seedersNamespace, true); - + if ($count === 0) { $this->info('No migrations found.'); + return 0; } - + + $this->validateTargetConnections($this->runner, App::getConfig()->getDBConnections()); + return $this->runMigrations(); - } catch (Throwable $e) { $msg = $e->getMessage(); + if ((str_contains($msg, ".schema_changes' doesn't exist") && $e->getCode() == 1146)) { $this->warning('Table "schema_changes" does not exist. No migrations executed.'); $this->info('Run "migrations:ini" to create the table.'); + return 1; } $this->error('An exception was thrown.'); - $this->println('Message: ' . $e->getMessage()); - $this->println('File: ' . $e->getFile() . ':' . $e->getLine()); + $this->println('Message: '.$e->getMessage()); + $this->println('File: '.$e->getFile().':'.$e->getLine()); + return 1; } finally { if ($this->runner !== null) { @@ -77,73 +94,170 @@ public function exec(): int { } } } - + private function getConnection(): ?ConnectionInfo { $connections = App::getConfig()->getDBConnections(); - + if (empty($connections)) { $this->info('No database connections configured.'); + return null; } - + $connectionName = $this->getArgValue('--connection'); - + if ($connectionName !== null) { $connection = App::getConfig()->getDBConnection($connectionName); + if ($connection === null) { $this->error("Connection '$connectionName' not found."); + return null; } + return $connection; } - + return CLIUtils::getConnectionName($this); } - + + private function runAllConnections(): int { + $connections = App::getConfig()->getDBConnections(); + + if (empty($connections)) { + $this->info('No database connections configured.'); + + return 0; + } + + $env = $this->getArgValue('--env') ?? 'dev'; + $hasFailure = false; + + foreach ($connections as $name => $connection) { + $this->println(''); + $this->println('=== Connection: '.$name.' ==='); + + try { + $runner = new SchemaRunner($connection, $env); + + $migrationsPath = APP_PATH.'Database'.DS.'Migrations'; + $namespace = APP_DIR.'\\Database\\Migrations'; + $count = $runner->discoverFromPath($migrationsPath, $namespace, true); + + $seedersPath = APP_PATH.'Database'.DS.'Seeders'; + $seedersNamespace = APP_DIR.'\\Database\\Seeders'; + $count += $runner->discoverFromPath($seedersPath, $seedersNamespace, true); + + if ($count === 0) { + $this->info('No migrations found.'); + $runner->close(); + continue; + } + + $this->validateTargetConnections($runner, $connections); + + $runner->createSchemaTable(); + $result = $runner->apply(); + + foreach ($result->getApplied() as $change) { + $this->success('Applied: '.$change->getName()); + } + + foreach ($result->getSkipped() as $item) { + $this->warning('Skipped: '.$item['change']->getName().' ('.$item['reason'].')'); + } + + foreach ($result->getFailed() as $item) { + $this->error('Failed: '.$item['change']->getName()); + $this->println(' Error: '.$item['error']->getMessage()); + $hasFailure = true; + } + + $applied = count($result->getApplied()); + $this->info("Applied: $applied change(s). Time: ".round($result->getTotalTime(), 2).'ms'); + + $runner->close(); + } catch (Throwable $e) { + $this->error('Error on connection '.$name.': '.$e->getMessage()); + $hasFailure = true; + } + } + + return $hasFailure ? 1 : 0; + } + private function runMigrations(): int { $this->println('Running migrations...'); - + $result = $this->runner->apply(); - + $applied = $result->getApplied(); + if (!empty($applied)) { foreach ($applied as $change) { - $this->success('Applied: ' . $change->getName()); + $this->success('Applied: '.$change->getName()); } } - + $skipped = $result->getSkipped(); + if (!empty($skipped)) { foreach ($skipped as $item) { - $this->warning('Skipped: ' . $item['change']->getName() . ' (' . $item['reason'] . ')'); + $this->warning('Skipped: '.$item['change']->getName().' ('.$item['reason'].')'); } } - + $failed = $result->getFailed(); + if (!empty($failed)) { foreach ($failed as $item) { - $this->error('Failed: ' . $item['change']->getName()); - $this->println(' Error: ' . $item['error']->getMessage()); + $this->error('Failed: '.$item['change']->getName()); + $this->println(' Error: '.$item['error']->getMessage()); } } - + $migrationsCount = count(array_filter($result->getApplied(), fn($c) => $c->getType() === 'migration')); $seedersCount = count(array_filter($result->getApplied(), fn($c) => $c->getType() === 'seeder')); if ($migrationsCount > 0) { - $this->info('Applied: ' . $migrationsCount . ' migration(s)'); + $this->info('Applied: '.$migrationsCount.' migration(s)'); } if ($seedersCount > 0) { - $this->info('Applied: ' . $seedersCount . ' seeder(s)'); + $this->info('Applied: '.$seedersCount.' seeder(s)'); } if ($migrationsCount === 0 && $seedersCount === 0) { $this->info('Applied: 0 migrations'); } - $this->info('Time: ' . round($result->getTotalTime(), 2) . 'ms'); - + $this->info('Time: '.round($result->getTotalTime(), 2).'ms'); + return !empty($failed) ? 1 : 0; } + + private function validateTargetConnections(SchemaRunner $runner, array $registeredConnections): void { + $registeredNames = array_keys($registeredConnections); + $currentName = $runner->getConnectionInfo()->getName(); + + $hasTargeted = false; + + foreach ($runner->getChanges() as $change) { + $targets = $change->getTargetConnections(); + + if (!empty($targets)) { + $hasTargeted = true; + + foreach ($targets as $target) { + if (!in_array($target, $registeredNames)) { + $this->warning('Migration '.$change->getName().' targets unknown connection: '.$target); + } + } + } + } + + if ($hasTargeted && $currentName === 'New_Connection') { + $this->warning('Connection has default name "New_Connection". Connection-targeted migrations may not filter correctly. Set a name via ConnectionInfo::setName().'); + } + } } diff --git a/WebFiori/Framework/Cli/Commands/SkipMigrationsCommand.php b/WebFiori/Framework/Cli/Commands/SkipMigrationsCommand.php index 79b9c7bcb..c47c17d6a 100644 --- a/WebFiori/Framework/Cli/Commands/SkipMigrationsCommand.php +++ b/WebFiori/Framework/Cli/Commands/SkipMigrationsCommand.php @@ -1,4 +1,5 @@ getConnection(); + if ($connection === null) { return 1; } - + $env = $this->getArgValue('--env') ?? 'dev'; $this->runner = new SchemaRunner($connection, $env); - + // Discover migrations $migrationsPath = APP_PATH.'Database'.DS.'Migrations'; $namespace = APP_DIR.'\\Database\\Migrations'; @@ -55,24 +56,27 @@ public function exec(): int { $seedersPath = APP_PATH.'Database'.DS.'Seeders'; $seedersNamespace = APP_DIR.'\\Database\\Seeders'; $count += $this->runner->discoverFromPath($seedersPath, $seedersNamespace, true); - + if ($count === 0) { $this->info('No migrations found.'); + return 0; } - + return $this->skip(); - } catch (Throwable $e) { $msg = $e->getMessage(); + if (str_contains($msg, ".schema_changes' doesn't exist") && $e->getCode() == 1146) { $this->warning('Table "schema_changes" does not exist.'); $this->info('Run "migrations:ini" to create the table.'); + return 1; } $this->error('An exception was thrown.'); - $this->println('Message: ' . $e->getMessage()); - $this->println('File: ' . $e->getFile() . ':' . $e->getLine()); + $this->println('Message: '.$e->getMessage()); + $this->println('File: '.$e->getFile().':'.$e->getLine()); + return 1; } finally { if ($this->runner !== null) { @@ -80,29 +84,33 @@ public function exec(): int { } } } - + private function getConnection(): ?ConnectionInfo { $connections = App::getConfig()->getDBConnections(); - + if (empty($connections)) { $this->info('No database connections configured.'); + return null; } - + $connectionName = $this->getArgValue('--connection'); - + if ($connectionName !== null) { $connection = App::getConfig()->getDBConnection($connectionName); + if ($connection === null) { $this->error("Connection '$connectionName' not found."); + return null; } + return $connection; } - + return CLIUtils::getConnectionName($this); } - + private function skip(): int { if ($this->isArgProvided('--all')) { return $this->skipAll(); @@ -111,54 +119,59 @@ private function skip(): int { } else if ($this->isArgProvided('--up-to')) { return $this->skipUpTo(); } - + $this->error('Provide --name, --all, or --up-to.'); + return 1; } - + private function skipAll(): int { $skipped = $this->runner->skipAll(); - + if (empty($skipped)) { $this->info('No pending migrations to skip.'); + return 0; } - + foreach ($skipped as $change) { - $this->success('Skipped: ' . $change->getName()); + $this->success('Skipped: '.$change->getName()); } - $this->info('Total skipped: ' . count($skipped)); - + $this->info('Total skipped: '.count($skipped)); + return 0; } - + private function skipSingle(): int { $name = $this->getArgValue('--name'); $result = $this->runner->skip($name); - + if ($result) { - $this->success('Skipped: ' . $name); + $this->success('Skipped: '.$name); + return 0; } - - $this->warning('Could not skip: ' . $name . ' (not found or already applied)'); + + $this->warning('Could not skip: '.$name.' (not found or already applied)'); + return 1; } - + private function skipUpTo(): int { $name = $this->getArgValue('--up-to'); $skipped = $this->runner->skipUpTo($name); - + if (empty($skipped)) { $this->info('No pending migrations to skip.'); + return 0; } - + foreach ($skipped as $change) { - $this->success('Skipped: ' . $change->getName()); + $this->success('Skipped: '.$change->getName()); } - $this->info('Total skipped: ' . count($skipped)); - + $this->info('Total skipped: '.count($skipped)); + return 0; } } diff --git a/WebFiori/Framework/Config/JsonDriver.php b/WebFiori/Framework/Config/JsonDriver.php index 272b46d91..a0b1a1f04 100644 --- a/WebFiori/Framework/Config/JsonDriver.php +++ b/WebFiori/Framework/Config/JsonDriver.php @@ -1,813 +1,814 @@ -json = new Json([ - 'base-url' => 'DYNAMIC', - 'theme' => null, - 'home-page' => 'BASE_URL', - 'primary-lang' => 'EN', - 'titles' => new Json([ - 'AR' => 'افتراضي', - 'EN' => 'Default' - ], 'none', 'same'), - 'name-separator' => '|', - 'scheduler-password' => 'NO_PASSWORD', - 'app-names' => new Json([ - 'AR' => 'تطبيق', - 'EN' => 'Application' - ], 'none', 'same'), - 'app-descriptions' => new Json([ - 'AR' => '', - 'EN' => '' - ]), - 'version-info' => new Json([ - 'version' => '1.0', - 'version-type' => 'Stable', - 'release-date' => date('Y-m-d') - ], 'none', 'same'), - 'env-vars' => new Json([ - 'WF_VERBOSE' => new Json([ - 'value' => false, - 'description' => 'Configure the verbosity of error messsages at run-time. This should be set to true in testing and false in production.' - ], 'none', 'same'), - "CLI_HTTP_HOST" => new Json([ - "value" => "127.0.0.1", - "description" => "Host name that will be used when runing the application as command line utility." - ], 'none', 'same') - ], 'none', 'same'), - 'smtp-connections' => new Json([], 'none', 'same'), - 'database-connections' => new Json([], 'none', 'same'), - ], 'none', 'same'); - $this->json->setIsFormatted(true); - } - /** - * Adds application environment variable to the configuration. - * - * The variables which are added using this method will be defined as - * a named constant at run time using the function 'define'. This means - * the constant will be accessable anywhere within the application's environment. - * - * Note: The value parameter supports the 'env:' prefix for referencing - * system environment variables (e.g., "env:MY_VAR"). - * - * @param string $name The name of the named constant such as 'MY_CONSTANT'. - * - * @param mixed $value The value of the constant. - * - * @param string $description An optional description to describe the porpuse - * of the constant. - */ - public function addEnvVar(string $name, mixed $value = null, ?string $description = null) { - $this->json->get('env-vars')->add($name, new Json([ - 'value' => $value, - 'description' => $description - ], 'none', 'same')); - $this->writeJson(); - } - /** - * Adds new database connections information or update existing connection. - * - * Note: When using this driver, connection properties support environment variable - * substitution using the 'env:' prefix. For example, you can set host to "env:DB_HOST" - * in the JSON configuration file, and it will be resolved at runtime. - * - * @param ConnectionInfo $dbConnectionsInfo An object which holds connection information. - */ - public function addOrUpdateDBConnection(ConnectionInfo $dbConnectionsInfo) { - $connectionJAsJson = new Json([ - 'type' => $dbConnectionsInfo->getDatabaseType(), - 'host' => $dbConnectionsInfo->getHost(), - 'port' => $dbConnectionsInfo->getPort(), - 'username' => $dbConnectionsInfo->getUsername(), - 'database' => $dbConnectionsInfo->getDBName(), - 'password' => $dbConnectionsInfo->getPassword(), - ], 'none', 'same'); - $connectionJAsJson->addArray('extras', $dbConnectionsInfo->getExtars(), true); - $this->json->get('database-connections')->add($dbConnectionsInfo->getName(), $connectionJAsJson); - $this->writeJson(); - } - /** - * Adds new SMTP account or Updates an existing one. - * - * Note: When using this driver, SMTP account properties support environment variable - * substitution using the 'env:' prefix. For example, you can set password to "env:SMTP_PASS" - * in the JSON configuration file, and it will be resolved at runtime. - * - * @param SMTPAccount $emailAccount An instance of 'SMTPAccount'. - */ - public function addOrUpdateSMTPAccount(SMTPAccount $emailAccount) { - $connectionAsJson = new Json([ - 'host' => $emailAccount->getServerAddress(), - 'port' => $emailAccount->getPort(), - 'username' => $emailAccount->getUsername(), - 'password' => $emailAccount->getPassword(), - 'address' => $emailAccount->getAddress(), - 'sender-name' => $emailAccount->getSenderName(), - 'access-token' => $emailAccount->getAccessToken() - ], 'none', 'same'); - $this->json->get('smtp-connections')->add($emailAccount->getAccountName(), $connectionAsJson); - $this->writeJson(); - } - /** - * Returns the name of the application in specific display language. - * - * @param string $langCode Language code such as 'AR'. - * - * @return string|null Application name or null if language code does not - * exist. - */ - public function getAppName(string $langCode) { - return $this->json->get('app-names')->get(strtoupper(trim($langCode))); - } - /** - * Returns an array that holds different names for the web application - * on different languages. - * - * @return array The indices of the array are language codes such as 'AR' and - * the value of the index is the name. - * - */ - public function getAppNames(): array { - $appNamesJson = $this->json->get('app-names'); - $retVal = []; - - foreach ($appNamesJson->getProperties() as $prob) { - $retVal[$prob->getName()] = $prob->getValue(); - } - - return $retVal; - } - - public function getAppReleaseDate() : string { - return $this->json->get('version-info')->get('release-date'); - } - - public function getAppVersion() : string { - return $this->json->get('version-info')->get('version'); - } - - public function getAppVersionType() : string { - return $this->json->get('version-info')->get('version-type'); - } - /** - * Returns the base URL of the application. - * - * Note that if the base is set so 'DYNAMIC' in the configuration, it will - * be auto-generated at run time. - * - * @return string A string such as 'http://example.com:8989'. - */ - public function getBaseURL(): string { - $val = $this->json->get('base-url'); - - if ($val == '' || $val == 'DYNAMIC') { - return Uri::getBaseURL(); - } - - return $val; - } - /** - * Returns the name of the file at which the application is using to - * read configuration. - * - * @return string - */ - public static function getConfigFileName() : string { - return self::$configFileName; - } - - /** - * Returns database connection information given connection name. - * - * Note: Connection properties that use the 'env:' prefix in configuration - * will be automatically resolved to their environment variable values. - * - * @param string $conName The name of the connection. - * - * @return ConnectionInfo|null Returns connection info if found, null otherwise. - */ - public function getDBConnection(string $conName) { - $jsonObj = $this->json->get('database-connections')->get($conName); - - if ($jsonObj !== null) { - $extras = $jsonObj->get('extras'); - $extrasArr = []; - - if ($extras instanceof Json) { - foreach ($extras->getProperties() as $prop) { - $extrasArr[$prop->getName()] = $prop->getValue(); - } - } else if (gettype($extras) == 'array') { - $extrasArr = $extras; - } - - return new ConnectionInfo( - $this->getProp($jsonObj, 'type', $conName), - $this->getProp($jsonObj, 'username', $conName), - $this->getProp($jsonObj, 'password', $conName), - $this->getProp($jsonObj, 'database', $conName), - $this->getProp($jsonObj, 'host', $conName), - $this->getProp($jsonObj, 'port', $conName), - $extrasArr); - } - } - /** - * Returns an associative array that contain the information of database connections. - * - * Note: Connection properties that use the 'env:' prefix in configuration - * will be automatically resolved to their environment variable values. - * - * @return array An associative array. The indices are connections names and - * values are objects of type 'ConnectionInfo'. - */ - public function getDBConnections(): array { - $accountsInfo = $this->json->get('database-connections'); - $retVal = []; - - foreach ($accountsInfo->getProperties() as $propObj) { - $name = $propObj->getName(); - $jsonObj = $propObj->getValue(); - $acc = new ConnectionInfo( - $this->getProp($jsonObj, 'type', $name), - $this->getProp($jsonObj, 'username', $name), - $this->getProp($jsonObj, 'password', $name), - $this->getProp($jsonObj, 'database', $name)); - $extrasObj = $jsonObj->get('extras'); - - if ($extrasObj !== null) { - $extrasArr = []; - if ($extrasObj instanceof Json) { - - - foreach ($extrasObj->getProperties() as $prop) { - $extrasArr[$prop->getName()] = $prop->getValue(); - } - } else if (gettype($extrasObj) == 'array') { - $extrasArr = $extrasObj; - } - $acc->setExtras($extrasArr); - } - $acc->setHost($this->getProp($jsonObj, 'host', $name)); - $acc->setName($propObj->getName()); - $acc->setPort($this->getProp($jsonObj, 'port', $name)); - $retVal[$propObj->getName()] = $acc; - } - - return $retVal; - } - - public function getDescription(string $langCode) { - return $this->json->get('app-descriptions')->get(strtoupper(trim($langCode))); - } - /** - * Returns an array that holds different descriptions for the web application - * on different languages. - * - * @return array The indices of the array are language codes such as 'AR' and - * the value of the index is the description. - */ - public function getDescriptions(): array { - $descriptions = $this->json->get('app-descriptions'); - $retVal = []; - - foreach ($descriptions->getProperties() as $prob) { - $retVal[$prob->getName()] = $prob->getValue(); - } - - return $retVal; - } - /** - * Returns an array that holds the information of defined application environment - * variables. - * - * Note: Environment variable values that use the 'env:' prefix in configuration - * will be automatically resolved to their system environment variable values. - * - * @return array The returned array will be associative. The key will represent - * the name of the variable and its value is a sub-associative array with - * two indices, 'description' and 'value'. The description index is a text that describes - * the variable and the value index will hold its value. - */ - public function getEnvVars(): array { - $retVal = []; - $vars = $this->json->get('env-vars'); - - foreach ($vars->getPropsNames() as $name) { - $value = ''; - $desc = null; - $val = $this->json->get('env-vars')->get($name); - - if (gettype($val) == 'object') { - $value = $val->get('value'); - $desc = $val->get('description'); - } else { - $value = $val; - } - $retVal[$name] = [ - 'value' => Controller::resolveEnvValue($value), - 'description' => $desc - ]; - } - - return $retVal; - } - /** - * Returns a string that represent the home page of the application. - * - * Note that if home page is set to 'BASE_URL' in configuration, the - * method will use the base URL as the home page. - * - * @return string - */ - public function getHomePage() : string { - $home = $this->json->get('home-page') ?? ''; - - if ($home == 'BASE_URL') { - return $this->getBaseURL(); - } - - return $home; - } - - public function getPrimaryLanguage(): string { - return $this->json->get('primary-lang'); - } - /** - * Returns sha256 hash of the password which is used to prevent unauthorized - * access to run background tasks or access scheduler web interface. - * - * The password should be hashed before using this method as this one should - * return the hashed value. If no password is set, this method will return the - * string 'NO_PASSWORD'. - * - * Note: The scheduler password value supports environment variable substitution - * using the 'env:' prefix (e.g., "env:SCHEDULER_PASS" in configuration). - * - * @return string Password hash or the string 'NO_PASSWORD' if there is no - * password. - */ - public function getSchedulerPassword(): string { - $pass = ''.$this->json->get('scheduler-password') ?? 'NO_PASSWORD'; - - if (strlen($pass) == 0 || $pass == 'NO_PASSWORD') { - return 'NO_PASSWORD'; - } - - return Controller::resolveEnvValue($pass); - } - /** - * Returns SMTP connection given its name. - * - * The method will search for an account with the given name in the set - * of added accounts. If no account was found, null is returned. - * - * Note: SMTP account properties that use the 'env:' prefix in configuration - * will be automatically resolved to their environment variable values. - * - * @param string $name The name of the account. - * - * @return SMTPAccount|null If the account is found, The method - * will return an object of type SMTPAccount. Else, the - * method will return null. - * - */ - public function getSMTPConnection(string $name) { - $jsonObj = $this->json->get('smtp-connections')->get($name); - - if ($jsonObj !== null) { - return new SMTPAccount([ - 'sender-address' => $this->getProp($jsonObj, 'address', $name), - 'pass' => $this->getProp($jsonObj, 'password', $name), - 'port' => $this->getProp($jsonObj, 'port', $name), - 'sender-name' => $this->getProp($jsonObj, 'sender-name', $name), - 'server-address' => $this->getProp($jsonObj, 'host', $name), - 'user' => $this->getProp($jsonObj, 'username', $name), - 'account-name' => $name, - 'access-token' => $this->getProp($jsonObj, 'access-token', $name, false) - ]); - } - } - /** - * Returns an array that contains all added SMTP accounts. - * - * Note: SMTP account properties that use the 'env:' prefix in configuration - * will be automatically resolved to their environment variable values. - * - * @return array An array that contains all added SMTP accounts. - */ - public function getSMTPConnections(): array { - $accountsInfo = $this->json->get('smtp-connections'); - $retVal = []; - - foreach ($accountsInfo->getProperties() as $prop) { - $name = $prop->getName(); - $jsonObj = $prop->getValue(); - $acc = new SMTPAccount(); - $acc->setAccountName($name); - - $acc->setAddress($this->getProp($jsonObj, 'address', $name)); - $acc->setPassword($this->getProp($jsonObj, 'password', $name)); - $acc->setPort($this->getProp($jsonObj, 'port', $name)); - $acc->setSenderName($this->getProp($jsonObj, 'sender-name', $name)); - $acc->setServerAddress($this->getProp($jsonObj, 'host', $name)); - $acc->setUsername($this->getProp($jsonObj, 'username', $name)); - $acc->setAccessToken($this->getProp($jsonObj, 'access-token', $name, false)); - $retVal[$name] = $acc; - } - - return $retVal; - } - /** - * Returns the name or the namespace of default theme that the application - * will use in case a page does not have specific theme. - * - * @return string - */ - public function getTheme(): string { - return $this->json->get('theme') ?? ''; - } - /** - * Returns the default title at which a web page will use in case no title - * is specified. - * - * @param string $lang A two-letter string that represents language code. - * The returned value will be specific to selected language. - * - * @return string The default title at which a web page will use in case no title - * is specified. - */ - public function getTitle(string $lang) : string { - $titles = $this->json->get('titles'); - $langU = strtoupper(trim($lang)); - - if (strlen($langU) == 0) { - return ''; - } - - foreach ($titles->getProperties() as $prob) { - if ($prob->getName() == $langU) { - return $prob->getValue(); - } - } - - return ''; - } - /** - * Returns an array that holds different page titles for the web application - * on different languages. - * - * @return array The indices of the array are language codes such as 'AR' and - * the value of the index is the title. - * - */ - public function getTitles(): array { - $titles = $this->json->get('titles'); - $retVal = []; - - foreach ($titles->getProperties() as $prob) { - $retVal[$prob->getName()] = $prob->getValue(); - } - - return $retVal; - } - /** - * Returns a string that represents the value which is used to separate the - * title of a web page from the name of the application. - * - * @return string - */ - public function getTitleSeparator(): string { - return $this->json->get('name-separator'); - } - /** - * Creates application configuration file. - * - * This method will attempt to create a JSON configuration file in the folder - * 'config' of the application. - * - * @param bool $reCreate If this parameter is set to true and there already a configuration - * file with same name, it will be overriden. - */ - public function initialize(bool $reCreate = false) { - $path = self::getConfigPath().self::getConfigFileName().'.json'; - - if (!file_exists($path) || $reCreate) { - $this->writeJson(); - } - $this->json = Json::fromJsonFile($path); - } - /** - * Deletes configuration file. - * - * Note that in order to remove specific configuration file, its name must - * be set using the method JsonDriver::setConfigFileName() - */ - public function remove() { - $f = new File(self::getConfigPath().self::getConfigFileName().'.json'); - $f->remove(); - } - public function removeAllDBConnections() { - $this->json->add('database-connections', new Json([], 'none', 'same')); - $this->writeJson(); - } - /** - * Removes all added SMTP connections. - */ - public function removeAllSMTPAccounts() { - $this->json->add('smtp-connections', new Json([], 'none', 'same')); - $this->writeJson(); - } - - public function removeDBConnection(string $connectionName) { - $connections = $this->getDBConnections(); - $accountNameTrimmed = trim($connectionName); - $toAdd = []; - - foreach ($connections as $connection) { - if ($connection->getName() != $accountNameTrimmed) { - $toAdd[] = $connection; - } - } - $this->removeAllDBConnections(); - - foreach ($toAdd as $account) { - $this->addOrUpdateDBConnection($account); - } - } - /** - * Removes specific application environment variable given its name. - * - * @param string $name The name of the variable. - */ - public function removeEnvVar(string $name) { - $this->json->get('env-vars')->remove($name); - $this->writeJson(); - } - /** - * Removes specific SMTP connection from the configuration given its name. - * - * @param string $accountName The name of the connection. - */ - public function removeSMTPAccount(string $accountName) { - $connections = $this->getSMTPConnections(); - $accountNameTrimmed = trim($accountName); - $toAdd = []; - - foreach ($connections as $connection) { - if ($connection->getAccountName() != $accountNameTrimmed) { - $toAdd[] = $connection; - } - } - $this->removeAllSMTPAccounts(); - - foreach ($toAdd as $account) { - $this->addOrUpdateSMTPAccount($account); - } - } - /** - * Sets or updates the name of the application for specific display language. - * - * @param string $name The name of the application. - * - * @param string $langCode The language code at which the name of the application will - * be updated for. - */ - public function setAppName(string $name, string $langCode) { - $code = $this->isValidLangCode($langCode); - - if ($code === false) { - return; - } - $appNamesJson = $this->json->get('app-names'); - $appNamesJson->add($code, $name); - $this->writeJson(); - } - /** - * Update application version information. - * - * @param string $vNum Version number such as 1.0.0. - * - * @param string $vType Version type such as 'Beta', 'Alpha' or 'RC'. - * - * @param string $releaseDate The date at which the version was released on. - * - */ - public function setAppVersion(string $vNum, string $vType, string $releaseDate) { - $this->json->add('version-info', new Json([ - 'version' => $vNum, - 'version-type' => $vType, - 'release-date' => $releaseDate - ], 'none', 'same')); - $this->writeJson(); - } - /** - * Sets the base URL of the application. - * - * This is usually used in fetching resources. - * - * @param string $url - */ - public function setBaseURL(string $url) { - $trim = trim($url); - - if (strlen($trim) == 0) { - $this->json->add('base-url', 'DYNAMIC'); - } else { - $this->json->add('base-url', $trim); - } - } - /** - * Sets the name of the file that configuration values will be taken from. - * - * The file must exist on the directory [APP_PATH]/Config/ . - * - * @param string $name - */ - public static function setConfigFileName(string $name) { - $split = explode('.', trim($name)); - - if (count($split) == 2) { - self::$configFileName = $split[0]; - } else if (count($split) == 1) { - self::$configFileName = trim($name); - } - } - /** - * Sets or update default description of the application that will be used - * by web pages. - * - * @param string $description The default description. - * - * @param string $langCode The code of the language at which the description - * will be updated for. - */ - public function setDescription(string $description, string $langCode) { - $code = $this->isValidLangCode($langCode); - - if ($code === false) { - return; - } - $appNamesJson = $this->json->get('app-descriptions'); - $appNamesJson->add($code, $description); - $this->writeJson(); - } - /** - * Sets the home page of the application. - * - * - * @param string $url The URL of the home page of the website. For example, - * This page is served when the user visits the domain without specifying a path. - */ - public function setHomePage(string $url) { - $trim = trim($url); - - if (strlen($trim) == 0) { - $this->json->add('home-page', 'BASE_URL'); - } else { - $this->json->add('home-page', $trim); - } - $this->writeJson(); - } - /** - * Update application version information. - * - * @param string $vNum Version number such as 1.0.0. - * - * @param string $vType Version type such as 'Beta', 'Alpha' or 'RC'. - * - * @param string $releaseDate The date at which the version was released on. - * - */ - public function setPrimaryLanguage(string $langCode) { - $code = $this->isValidLangCode($langCode); - - if ($code === false) { - return; - } - $this->json->add('primary-lang', $code); - $this->writeJson(); - } - /** - * Updates the password which is used to protect tasks from unauthorized - * execution. - * - * @param string $newPass The new password. Note that provided value - * must be hashed using SHA256 algorithm. - * - */ - public function setSchedulerPassword(string $newPass) { - $this->json->add('scheduler-password', $newPass); - $this->writeJson(); - } - /** - * Sets the default theme which will be used to style web pages. - * - * @param string $theme The name of the theme that will be used to style - * website UI. This can also be class name of the theme. - */ - public function setTheme(string $theme) { - $this->json->add('theme', trim($theme)); - $this->writeJson(); - } - /** - * Sets or updates default web page title for a specific display language. - * - * @param string $title The title that will be set. - * - * @param string $langCode The display language at which the title will be - * set or updated for. - */ - public function setTitle(string $title, string $langCode) { - $code = $this->isValidLangCode($langCode); - - if ($code === false) { - return; - } - $trimmedTitle = trim($title); - - if (strlen($trimmedTitle) == 0) { - return; - } - $appNamesJson = $this->json->get('titles'); - $appNamesJson->add($code, $trimmedTitle); - $this->writeJson(); - } - /** - * Sets the string which is used to separate application name from page name. - * - * @param string $separator A character or a string that is used - * to separate application name from web page title. Two common - * values are '-' and '|'. - */ - public function setTitleSeparator(string $separator) { - $trimmed = trim($separator); - - if (strlen($trimmed) != 0) { - $this->json->add('name-separator', $separator); - $this->writeJson(); - } - } - public function toJSON() : Json { - return $this->json; - } - private function getProp(Json $j, $name, string $connName, bool $requred = true) { - $val = $j->get($name); - - if ($val === null && $requred) { - throw new InitializationException('The property "'.$name.'" of the connection "'.$connName.'" is missing.'); - } - - return Controller::resolveEnvValue($val); - } - private function isValidLangCode($langCode) { - $code = strtoupper(trim($langCode)); - - if (strlen($code) != 2) { - return false; - } - - return $code; - } - private function writeJson() { - $file = new File(self::getConfigPath().self::getConfigFileName().'.json'); - $file->remove(); - $json = $this->toJSON(); - $json->setIsFormatted(true); - $file->setRawData($json.''); - $file->write(false, true); - } -} +json = new Json([ + 'base-url' => 'DYNAMIC', + 'theme' => null, + 'home-page' => 'BASE_URL', + 'primary-lang' => 'EN', + 'titles' => new Json([ + 'AR' => 'افتراضي', + 'EN' => 'Default' + ], 'none', 'same'), + 'name-separator' => '|', + 'scheduler-password' => 'NO_PASSWORD', + 'app-names' => new Json([ + 'AR' => 'تطبيق', + 'EN' => 'Application' + ], 'none', 'same'), + 'app-descriptions' => new Json([ + 'AR' => '', + 'EN' => '' + ]), + 'version-info' => new Json([ + 'version' => '1.0', + 'version-type' => 'Stable', + 'release-date' => date('Y-m-d') + ], 'none', 'same'), + 'env-vars' => new Json([ + 'WF_VERBOSE' => new Json([ + 'value' => false, + 'description' => 'Configure the verbosity of error messsages at run-time. This should be set to true in testing and false in production.' + ], 'none', 'same'), + "CLI_HTTP_HOST" => new Json([ + "value" => "127.0.0.1", + "description" => "Host name that will be used when runing the application as command line utility." + ], 'none', 'same') + ], 'none', 'same'), + 'smtp-connections' => new Json([], 'none', 'same'), + 'database-connections' => new Json([], 'none', 'same'), + ], 'none', 'same'); + $this->json->setIsFormatted(true); + } + /** + * Adds application environment variable to the configuration. + * + * The variables which are added using this method will be defined as + * a named constant at run time using the function 'define'. This means + * the constant will be accessable anywhere within the application's environment. + * + * Note: The value parameter supports the 'env:' prefix for referencing + * system environment variables (e.g., "env:MY_VAR"). + * + * @param string $name The name of the named constant such as 'MY_CONSTANT'. + * + * @param mixed $value The value of the constant. + * + * @param string $description An optional description to describe the porpuse + * of the constant. + */ + public function addEnvVar(string $name, mixed $value = null, ?string $description = null) { + $this->json->get('env-vars')->add($name, new Json([ + 'value' => $value, + 'description' => $description + ], 'none', 'same')); + $this->writeJson(); + } + /** + * Adds new database connections information or update existing connection. + * + * Note: When using this driver, connection properties support environment variable + * substitution using the 'env:' prefix. For example, you can set host to "env:DB_HOST" + * in the JSON configuration file, and it will be resolved at runtime. + * + * @param ConnectionInfo $dbConnectionsInfo An object which holds connection information. + */ + public function addOrUpdateDBConnection(ConnectionInfo $dbConnectionsInfo) { + $connectionJAsJson = new Json([ + 'type' => $dbConnectionsInfo->getDatabaseType(), + 'host' => $dbConnectionsInfo->getHost(), + 'port' => $dbConnectionsInfo->getPort(), + 'username' => $dbConnectionsInfo->getUsername(), + 'database' => $dbConnectionsInfo->getDBName(), + 'password' => $dbConnectionsInfo->getPassword(), + ], 'none', 'same'); + $connectionJAsJson->addArray('extras', $dbConnectionsInfo->getExtars(), true); + $this->json->get('database-connections')->add($dbConnectionsInfo->getName(), $connectionJAsJson); + $this->writeJson(); + } + /** + * Adds new SMTP account or Updates an existing one. + * + * Note: When using this driver, SMTP account properties support environment variable + * substitution using the 'env:' prefix. For example, you can set password to "env:SMTP_PASS" + * in the JSON configuration file, and it will be resolved at runtime. + * + * @param SMTPAccount $emailAccount An instance of 'SMTPAccount'. + */ + public function addOrUpdateSMTPAccount(SMTPAccount $emailAccount) { + $connectionAsJson = new Json([ + 'host' => $emailAccount->getServerAddress(), + 'port' => $emailAccount->getPort(), + 'username' => $emailAccount->getUsername(), + 'password' => $emailAccount->getPassword(), + 'address' => $emailAccount->getAddress(), + 'sender-name' => $emailAccount->getSenderName(), + 'access-token' => $emailAccount->getAccessToken() + ], 'none', 'same'); + $this->json->get('smtp-connections')->add($emailAccount->getAccountName(), $connectionAsJson); + $this->writeJson(); + } + /** + * Returns the name of the application in specific display language. + * + * @param string $langCode Language code such as 'AR'. + * + * @return string|null Application name or null if language code does not + * exist. + */ + public function getAppName(string $langCode) { + return $this->json->get('app-names')->get(strtoupper(trim($langCode))); + } + /** + * Returns an array that holds different names for the web application + * on different languages. + * + * @return array The indices of the array are language codes such as 'AR' and + * the value of the index is the name. + * + */ + public function getAppNames(): array { + $appNamesJson = $this->json->get('app-names'); + $retVal = []; + + foreach ($appNamesJson->getProperties() as $prob) { + $retVal[$prob->getName()] = $prob->getValue(); + } + + return $retVal; + } + + public function getAppReleaseDate() : string { + return $this->json->get('version-info')->get('release-date'); + } + + public function getAppVersion() : string { + return $this->json->get('version-info')->get('version'); + } + + public function getAppVersionType() : string { + return $this->json->get('version-info')->get('version-type'); + } + /** + * Returns the base URL of the application. + * + * Note that if the base is set so 'DYNAMIC' in the configuration, it will + * be auto-generated at run time. + * + * @return string A string such as 'http://example.com:8989'. + */ + public function getBaseURL(): string { + $val = $this->json->get('base-url'); + + if ($val == '' || $val == 'DYNAMIC') { + return Uri::getBaseURL(); + } + + return $val; + } + /** + * Returns the name of the file at which the application is using to + * read configuration. + * + * @return string + */ + public static function getConfigFileName() : string { + return self::$configFileName; + } + + /** + * Returns database connection information given connection name. + * + * Note: Connection properties that use the 'env:' prefix in configuration + * will be automatically resolved to their environment variable values. + * + * @param string $conName The name of the connection. + * + * @return ConnectionInfo|null Returns connection info if found, null otherwise. + */ + public function getDBConnection(string $conName) { + $jsonObj = $this->json->get('database-connections')->get($conName); + + if ($jsonObj !== null) { + $extras = $jsonObj->get('extras'); + $extrasArr = []; + + if ($extras instanceof Json) { + foreach ($extras->getProperties() as $prop) { + $extrasArr[$prop->getName()] = $prop->getValue(); + } + } else if (gettype($extras) == 'array') { + $extrasArr = $extras; + } + $extrasArr['connection-name'] = trim($conName); + + return new ConnectionInfo( + $this->getProp($jsonObj, 'type', $conName), + $this->getProp($jsonObj, 'username', $conName), + $this->getProp($jsonObj, 'password', $conName), + $this->getProp($jsonObj, 'database', $conName), + $this->getProp($jsonObj, 'host', $conName), + $this->getProp($jsonObj, 'port', $conName), + $extrasArr); + } + } + /** + * Returns an associative array that contain the information of database connections. + * + * Note: Connection properties that use the 'env:' prefix in configuration + * will be automatically resolved to their environment variable values. + * + * @return array An associative array. The indices are connections names and + * values are objects of type 'ConnectionInfo'. + */ + public function getDBConnections(): array { + $accountsInfo = $this->json->get('database-connections'); + $retVal = []; + + foreach ($accountsInfo->getProperties() as $propObj) { + $name = $propObj->getName(); + $jsonObj = $propObj->getValue(); + $acc = new ConnectionInfo( + $this->getProp($jsonObj, 'type', $name), + $this->getProp($jsonObj, 'username', $name), + $this->getProp($jsonObj, 'password', $name), + $this->getProp($jsonObj, 'database', $name)); + $extrasObj = $jsonObj->get('extras'); + + if ($extrasObj !== null) { + $extrasArr = []; + if ($extrasObj instanceof Json) { + + + foreach ($extrasObj->getProperties() as $prop) { + $extrasArr[$prop->getName()] = $prop->getValue(); + } + } else if (gettype($extrasObj) == 'array') { + $extrasArr = $extrasObj; + } + $acc->setExtras($extrasArr); + } + $acc->setHost($this->getProp($jsonObj, 'host', $name)); + $acc->setName($propObj->getName()); + $acc->setPort($this->getProp($jsonObj, 'port', $name)); + $retVal[$propObj->getName()] = $acc; + } + + return $retVal; + } + + public function getDescription(string $langCode) { + return $this->json->get('app-descriptions')->get(strtoupper(trim($langCode))); + } + /** + * Returns an array that holds different descriptions for the web application + * on different languages. + * + * @return array The indices of the array are language codes such as 'AR' and + * the value of the index is the description. + */ + public function getDescriptions(): array { + $descriptions = $this->json->get('app-descriptions'); + $retVal = []; + + foreach ($descriptions->getProperties() as $prob) { + $retVal[$prob->getName()] = $prob->getValue(); + } + + return $retVal; + } + /** + * Returns an array that holds the information of defined application environment + * variables. + * + * Note: Environment variable values that use the 'env:' prefix in configuration + * will be automatically resolved to their system environment variable values. + * + * @return array The returned array will be associative. The key will represent + * the name of the variable and its value is a sub-associative array with + * two indices, 'description' and 'value'. The description index is a text that describes + * the variable and the value index will hold its value. + */ + public function getEnvVars(): array { + $retVal = []; + $vars = $this->json->get('env-vars'); + + foreach ($vars->getPropsNames() as $name) { + $value = ''; + $desc = null; + $val = $this->json->get('env-vars')->get($name); + + if (gettype($val) == 'object') { + $value = $val->get('value'); + $desc = $val->get('description'); + } else { + $value = $val; + } + $retVal[$name] = [ + 'value' => Controller::resolveEnvValue($value), + 'description' => $desc + ]; + } + + return $retVal; + } + /** + * Returns a string that represent the home page of the application. + * + * Note that if home page is set to 'BASE_URL' in configuration, the + * method will use the base URL as the home page. + * + * @return string + */ + public function getHomePage() : string { + $home = $this->json->get('home-page') ?? ''; + + if ($home == 'BASE_URL') { + return $this->getBaseURL(); + } + + return $home; + } + + public function getPrimaryLanguage(): string { + return $this->json->get('primary-lang'); + } + /** + * Returns sha256 hash of the password which is used to prevent unauthorized + * access to run background tasks or access scheduler web interface. + * + * The password should be hashed before using this method as this one should + * return the hashed value. If no password is set, this method will return the + * string 'NO_PASSWORD'. + * + * Note: The scheduler password value supports environment variable substitution + * using the 'env:' prefix (e.g., "env:SCHEDULER_PASS" in configuration). + * + * @return string Password hash or the string 'NO_PASSWORD' if there is no + * password. + */ + public function getSchedulerPassword(): string { + $pass = ''.$this->json->get('scheduler-password') ?? 'NO_PASSWORD'; + + if (strlen($pass) == 0 || $pass == 'NO_PASSWORD') { + return 'NO_PASSWORD'; + } + + return Controller::resolveEnvValue($pass); + } + /** + * Returns SMTP connection given its name. + * + * The method will search for an account with the given name in the set + * of added accounts. If no account was found, null is returned. + * + * Note: SMTP account properties that use the 'env:' prefix in configuration + * will be automatically resolved to their environment variable values. + * + * @param string $name The name of the account. + * + * @return SMTPAccount|null If the account is found, The method + * will return an object of type SMTPAccount. Else, the + * method will return null. + * + */ + public function getSMTPConnection(string $name) { + $jsonObj = $this->json->get('smtp-connections')->get($name); + + if ($jsonObj !== null) { + return new SMTPAccount([ + 'sender-address' => $this->getProp($jsonObj, 'address', $name), + 'pass' => $this->getProp($jsonObj, 'password', $name), + 'port' => $this->getProp($jsonObj, 'port', $name), + 'sender-name' => $this->getProp($jsonObj, 'sender-name', $name), + 'server-address' => $this->getProp($jsonObj, 'host', $name), + 'user' => $this->getProp($jsonObj, 'username', $name), + 'account-name' => $name, + 'access-token' => $this->getProp($jsonObj, 'access-token', $name, false) + ]); + } + } + /** + * Returns an array that contains all added SMTP accounts. + * + * Note: SMTP account properties that use the 'env:' prefix in configuration + * will be automatically resolved to their environment variable values. + * + * @return array An array that contains all added SMTP accounts. + */ + public function getSMTPConnections(): array { + $accountsInfo = $this->json->get('smtp-connections'); + $retVal = []; + + foreach ($accountsInfo->getProperties() as $prop) { + $name = $prop->getName(); + $jsonObj = $prop->getValue(); + $acc = new SMTPAccount(); + $acc->setAccountName($name); + + $acc->setAddress($this->getProp($jsonObj, 'address', $name)); + $acc->setPassword($this->getProp($jsonObj, 'password', $name)); + $acc->setPort($this->getProp($jsonObj, 'port', $name)); + $acc->setSenderName($this->getProp($jsonObj, 'sender-name', $name)); + $acc->setServerAddress($this->getProp($jsonObj, 'host', $name)); + $acc->setUsername($this->getProp($jsonObj, 'username', $name)); + $acc->setAccessToken($this->getProp($jsonObj, 'access-token', $name, false)); + $retVal[$name] = $acc; + } + + return $retVal; + } + /** + * Returns the name or the namespace of default theme that the application + * will use in case a page does not have specific theme. + * + * @return string + */ + public function getTheme(): string { + return $this->json->get('theme') ?? ''; + } + /** + * Returns the default title at which a web page will use in case no title + * is specified. + * + * @param string $lang A two-letter string that represents language code. + * The returned value will be specific to selected language. + * + * @return string The default title at which a web page will use in case no title + * is specified. + */ + public function getTitle(string $lang) : string { + $titles = $this->json->get('titles'); + $langU = strtoupper(trim($lang)); + + if (strlen($langU) == 0) { + return ''; + } + + foreach ($titles->getProperties() as $prob) { + if ($prob->getName() == $langU) { + return $prob->getValue(); + } + } + + return ''; + } + /** + * Returns an array that holds different page titles for the web application + * on different languages. + * + * @return array The indices of the array are language codes such as 'AR' and + * the value of the index is the title. + * + */ + public function getTitles(): array { + $titles = $this->json->get('titles'); + $retVal = []; + + foreach ($titles->getProperties() as $prob) { + $retVal[$prob->getName()] = $prob->getValue(); + } + + return $retVal; + } + /** + * Returns a string that represents the value which is used to separate the + * title of a web page from the name of the application. + * + * @return string + */ + public function getTitleSeparator(): string { + return $this->json->get('name-separator'); + } + /** + * Creates application configuration file. + * + * This method will attempt to create a JSON configuration file in the folder + * 'config' of the application. + * + * @param bool $reCreate If this parameter is set to true and there already a configuration + * file with same name, it will be overriden. + */ + public function initialize(bool $reCreate = false) { + $path = self::getConfigPath().self::getConfigFileName().'.json'; + + if (!file_exists($path) || $reCreate) { + $this->writeJson(); + } + $this->json = Json::fromJsonFile($path); + } + /** + * Deletes configuration file. + * + * Note that in order to remove specific configuration file, its name must + * be set using the method JsonDriver::setConfigFileName() + */ + public function remove() { + $f = new File(self::getConfigPath().self::getConfigFileName().'.json'); + $f->remove(); + } + public function removeAllDBConnections() { + $this->json->add('database-connections', new Json([], 'none', 'same')); + $this->writeJson(); + } + /** + * Removes all added SMTP connections. + */ + public function removeAllSMTPAccounts() { + $this->json->add('smtp-connections', new Json([], 'none', 'same')); + $this->writeJson(); + } + + public function removeDBConnection(string $connectionName) { + $connections = $this->getDBConnections(); + $accountNameTrimmed = trim($connectionName); + $toAdd = []; + + foreach ($connections as $connection) { + if ($connection->getName() != $accountNameTrimmed) { + $toAdd[] = $connection; + } + } + $this->removeAllDBConnections(); + + foreach ($toAdd as $account) { + $this->addOrUpdateDBConnection($account); + } + } + /** + * Removes specific application environment variable given its name. + * + * @param string $name The name of the variable. + */ + public function removeEnvVar(string $name) { + $this->json->get('env-vars')->remove($name); + $this->writeJson(); + } + /** + * Removes specific SMTP connection from the configuration given its name. + * + * @param string $accountName The name of the connection. + */ + public function removeSMTPAccount(string $accountName) { + $connections = $this->getSMTPConnections(); + $accountNameTrimmed = trim($accountName); + $toAdd = []; + + foreach ($connections as $connection) { + if ($connection->getAccountName() != $accountNameTrimmed) { + $toAdd[] = $connection; + } + } + $this->removeAllSMTPAccounts(); + + foreach ($toAdd as $account) { + $this->addOrUpdateSMTPAccount($account); + } + } + /** + * Sets or updates the name of the application for specific display language. + * + * @param string $name The name of the application. + * + * @param string $langCode The language code at which the name of the application will + * be updated for. + */ + public function setAppName(string $name, string $langCode) { + $code = $this->isValidLangCode($langCode); + + if ($code === false) { + return; + } + $appNamesJson = $this->json->get('app-names'); + $appNamesJson->add($code, $name); + $this->writeJson(); + } + /** + * Update application version information. + * + * @param string $vNum Version number such as 1.0.0. + * + * @param string $vType Version type such as 'Beta', 'Alpha' or 'RC'. + * + * @param string $releaseDate The date at which the version was released on. + * + */ + public function setAppVersion(string $vNum, string $vType, string $releaseDate) { + $this->json->add('version-info', new Json([ + 'version' => $vNum, + 'version-type' => $vType, + 'release-date' => $releaseDate + ], 'none', 'same')); + $this->writeJson(); + } + /** + * Sets the base URL of the application. + * + * This is usually used in fetching resources. + * + * @param string $url + */ + public function setBaseURL(string $url) { + $trim = trim($url); + + if (strlen($trim) == 0) { + $this->json->add('base-url', 'DYNAMIC'); + } else { + $this->json->add('base-url', $trim); + } + } + /** + * Sets the name of the file that configuration values will be taken from. + * + * The file must exist on the directory [APP_PATH]/Config/ . + * + * @param string $name + */ + public static function setConfigFileName(string $name) { + $split = explode('.', trim($name)); + + if (count($split) == 2) { + self::$configFileName = $split[0]; + } else if (count($split) == 1) { + self::$configFileName = trim($name); + } + } + /** + * Sets or update default description of the application that will be used + * by web pages. + * + * @param string $description The default description. + * + * @param string $langCode The code of the language at which the description + * will be updated for. + */ + public function setDescription(string $description, string $langCode) { + $code = $this->isValidLangCode($langCode); + + if ($code === false) { + return; + } + $appNamesJson = $this->json->get('app-descriptions'); + $appNamesJson->add($code, $description); + $this->writeJson(); + } + /** + * Sets the home page of the application. + * + * + * @param string $url The URL of the home page of the website. For example, + * This page is served when the user visits the domain without specifying a path. + */ + public function setHomePage(string $url) { + $trim = trim($url); + + if (strlen($trim) == 0) { + $this->json->add('home-page', 'BASE_URL'); + } else { + $this->json->add('home-page', $trim); + } + $this->writeJson(); + } + /** + * Update application version information. + * + * @param string $vNum Version number such as 1.0.0. + * + * @param string $vType Version type such as 'Beta', 'Alpha' or 'RC'. + * + * @param string $releaseDate The date at which the version was released on. + * + */ + public function setPrimaryLanguage(string $langCode) { + $code = $this->isValidLangCode($langCode); + + if ($code === false) { + return; + } + $this->json->add('primary-lang', $code); + $this->writeJson(); + } + /** + * Updates the password which is used to protect tasks from unauthorized + * execution. + * + * @param string $newPass The new password. Note that provided value + * must be hashed using SHA256 algorithm. + * + */ + public function setSchedulerPassword(string $newPass) { + $this->json->add('scheduler-password', $newPass); + $this->writeJson(); + } + /** + * Sets the default theme which will be used to style web pages. + * + * @param string $theme The name of the theme that will be used to style + * website UI. This can also be class name of the theme. + */ + public function setTheme(string $theme) { + $this->json->add('theme', trim($theme)); + $this->writeJson(); + } + /** + * Sets or updates default web page title for a specific display language. + * + * @param string $title The title that will be set. + * + * @param string $langCode The display language at which the title will be + * set or updated for. + */ + public function setTitle(string $title, string $langCode) { + $code = $this->isValidLangCode($langCode); + + if ($code === false) { + return; + } + $trimmedTitle = trim($title); + + if (strlen($trimmedTitle) == 0) { + return; + } + $appNamesJson = $this->json->get('titles'); + $appNamesJson->add($code, $trimmedTitle); + $this->writeJson(); + } + /** + * Sets the string which is used to separate application name from page name. + * + * @param string $separator A character or a string that is used + * to separate application name from web page title. Two common + * values are '-' and '|'. + */ + public function setTitleSeparator(string $separator) { + $trimmed = trim($separator); + + if (strlen($trimmed) != 0) { + $this->json->add('name-separator', $separator); + $this->writeJson(); + } + } + public function toJSON() : Json { + return $this->json; + } + private function getProp(Json $j, $name, string $connName, bool $requred = true) { + $val = $j->get($name); + + if ($val === null && $requred) { + throw new InitializationException('The property "'.$name.'" of the connection "'.$connName.'" is missing.'); + } + + return Controller::resolveEnvValue($val); + } + private function isValidLangCode($langCode) { + $code = strtoupper(trim($langCode)); + + if (strlen($code) != 2) { + return false; + } + + return $code; + } + private function writeJson() { + $file = new File(self::getConfigPath().self::getConfigFileName().'.json'); + $file->remove(); + $json = $this->toJSON(); + $json->setIsFormatted(true); + $file->setRawData($json.''); + $file->write(false, true); + } +} diff --git a/tests/WebFiori/Framework/Tests/Cli/ConnectionTargetedMigrationsTest.php b/tests/WebFiori/Framework/Tests/Cli/ConnectionTargetedMigrationsTest.php new file mode 100644 index 000000000..da556f873 --- /dev/null +++ b/tests/WebFiori/Framework/Tests/Cli/ConnectionTargetedMigrationsTest.php @@ -0,0 +1,326 @@ +executeMultiCommand([ + RunMigrationsCommandNew::class, + '--all-connections' => '', + '--connection' => 'mysql-db' + ]); + + $outputStr = implode('', $output); + $this->assertStringContainsString('Cannot use --all-connections and --connection together', $outputStr); + $this->assertEquals(1, $this->getExitCode()); + } + + /** + * @test + */ + public function testAllConnectionsMixedTargeting() { + // Create migrations: one for mysql, one for mssql (simulated as second mysql conn), one for all + $this->createTargetedMigration('MysqlOnly', "['mysql-db']"); + $this->createTargetedMigration('SecondOnly', "['second-db']"); + $this->createTargetedMigration('Universal', '[]'); + + // Add a second mysql connection with different name (same physical DB for testing) + $secondConn = new ConnectionInfo('mysql', 'root', MYSQL_ROOT_PASSWORD, 'testing_db', '127.0.0.1', 3306); + $secondConn->setName('second-db'); + App::getConfig()->addOrUpdateDBConnection($secondConn); + + $output = $this->executeMultiCommand([ + RunMigrationsCommandNew::class, + '--all-connections' => '' + ]); + + $outputStr = implode('', $output); + + // mysql-db section should apply MysqlOnly + Universal, skip SecondOnly + $this->assertStringContainsString('=== Connection: mysql-db ===', $outputStr); + $this->assertStringContainsString('=== Connection: second-db ===', $outputStr); + $this->assertEquals(0, $this->getExitCode()); + } + + /** + * @test + */ + public function testAllConnectionsNoConnections() { + App::getConfig()->removeAllDBConnections(); + + $output = $this->executeMultiCommand([ + RunMigrationsCommandNew::class, + '--all-connections' => '' + ]); + + $outputStr = implode('', $output); + $this->assertStringContainsString('No database connections configured', $outputStr); + $this->assertEquals(0, $this->getExitCode()); + } + + /** + * @test + */ + public function testAllConnectionsRunsOnBoth() { + $this->createTargetedMigration('ForMysql', "['mysql-db']"); + $this->createTargetedMigration('ForAll', '[]'); + + $this->initMigrationsFor('mysql-db'); + + $output = $this->executeMultiCommand([ + RunMigrationsCommandNew::class, + '--all-connections' => '' + ]); + + $outputStr = implode('', $output); + $this->assertStringContainsString('=== Connection: mysql-db ===', $outputStr); + $this->assertStringContainsString('Applied: App\\Database\\Migrations\\ForAll', $outputStr); + $this->assertStringContainsString('Applied: App\\Database\\Migrations\\ForMysql', $outputStr); + $this->assertEquals(0, $this->getExitCode()); + } + + /** + * @test - Integration: targeted migration on MSSQL connection + */ + public function testMssqlTargetedMigration() { + try { + $this->setupMssqlConnection(); + } catch (\Throwable $e) { + $this->markTestSkipped('MSSQL connection not available: '.$e->getMessage()); + + return; + } + + $this->createTargetedMigration('MssqlMig', "['mssql-db']"); + + // Run against mysql - should skip + $this->initMigrationsFor('mysql-db'); + $output = $this->executeMultiCommand([ + RunMigrationsCommandNew::class, + '--connection' => 'mysql-db' + ]); + + $outputStr = implode('', $output); + $this->assertStringContainsString('Skipped: App\\Database\\Migrations\\MssqlMig', $outputStr); + + // Run against mssql - should apply + $this->initMigrationsFor('mssql-db'); + $output2 = $this->executeMultiCommand([ + RunMigrationsCommandNew::class, + '--connection' => 'mssql-db' + ]); + + $outputStr2 = implode('', $output2); + $this->assertStringContainsString('Applied: App\\Database\\Migrations\\MssqlMig', $outputStr2); + } + + /** + * @test + */ + public function testMultiTargetMigrationRunsOnEither() { + $this->createTargetedMigration('MultiTarget', "['mysql-db', 'mssql-db']"); + $this->initMigrationsFor('mysql-db'); + + $output = $this->executeMultiCommand([ + RunMigrationsCommandNew::class, + '--connection' => 'mysql-db' + ]); + + $outputStr = implode('', $output); + $this->assertStringContainsString('Applied: App\\Database\\Migrations\\MultiTarget', $outputStr); + $this->assertEquals(0, $this->getExitCode()); + } + + /** + * @test + */ + public function testTargetedMigrationRunsOnCorrectConnection() { + $this->createTargetedMigration('OnlyMysql', "['mysql-db']"); + $this->initMigrationsFor('mysql-db'); + + $output = $this->executeMultiCommand([ + RunMigrationsCommandNew::class, + '--connection' => 'mysql-db' + ]); + + $outputStr = implode('', $output); + $this->assertStringContainsString('Applied: App\\Database\\Migrations\\OnlyMysql', $outputStr); + $this->assertEquals(0, $this->getExitCode()); + } + + /** + * @test + */ + public function testTargetedMigrationSkippedOnWrongConnection() { + $this->createTargetedMigration('OnlyMssql', "['mssql-db']"); + $this->initMigrationsFor('mysql-db'); + + $output = $this->executeMultiCommand([ + RunMigrationsCommandNew::class, + '--connection' => 'mysql-db' + ]); + + $outputStr = implode('', $output); + $this->assertStringContainsString('Skipped: App\\Database\\Migrations\\OnlyMssql', $outputStr); + $this->assertStringContainsString('Connection mismatch', $outputStr); + $this->assertEquals(0, $this->getExitCode()); + } + + /** + * @test + */ + public function testValidationWarnsDefaultConnectionName() { + // Add a connection with default name + $defaultConn = new ConnectionInfo('mysql', 'root', MYSQL_ROOT_PASSWORD, 'testing_db', '127.0.0.1', 3306); + // Don't set name — it stays as 'New_Connection' + App::getConfig()->addOrUpdateDBConnection($defaultConn); + + $this->createTargetedMigration('SomeTargeted', "['mysql-db']"); + + $output = $this->executeMultiCommand([ + RunMigrationsCommandNew::class, + '--connection' => 'New_Connection' + ]); + + $outputStr = implode('', $output); + $this->assertStringContainsString('default name "New_Connection"', $outputStr); + } + + /** + * @test + */ + public function testValidationWarnsUnknownTargetConnection() { + $this->createTargetedMigration('BadTarget', "['nonexistent-db']"); + $this->initMigrationsFor('mysql-db'); + + $output = $this->executeMultiCommand([ + RunMigrationsCommandNew::class, + '--connection' => 'mysql-db' + ]); + + $outputStr = implode('', $output); + $this->assertStringContainsString('targets unknown connection: nonexistent-db', $outputStr); + } + + private function cleanupMigrations(): void { + $dir = APP_PATH.'Database'.DS.'Migrations'; + + if (!is_dir($dir)) { + return; + } + + $iterator = new \RecursiveIteratorIterator( + new \RecursiveDirectoryIterator($dir, \RecursiveDirectoryIterator::SKIP_DOTS), + \RecursiveIteratorIterator::CHILD_FIRST + ); + + foreach ($iterator as $item) { + if ($item->isFile() && $item->getExtension() === 'php') { + unlink($item->getRealPath()); + } + } + } + + // --- Helpers --- + + private function createTargetedMigration(string $name, string $targets): void { + $dir = APP_PATH.'Database'.DS.'Migrations'; + + if (!is_dir($dir)) { + mkdir($dir, 0755, true); + } + + $content = <<getDBConnection($connectionName); + + if ($conn !== null) { + $db = new \WebFiori\Database\Database($conn); + $db->raw("DROP TABLE IF EXISTS schema_changes")->execute(); + $db->close(); + } + } catch (\Throwable $e) { + // Ignore + } + } + + private function initMigrationsFor(string $connectionName): void { + $this->executeMultiCommand([ + InitMigrationsCommand::class, + '--connection' => $connectionName + ]); + } + + private function setupMssqlConnection(): void { + $password = getenv('SA_SQL_SERVER_PASSWORD') ?: '1234567890@Eu'; + $this->mssqlConnection = new ConnectionInfo('mssql', 'sa', $password, 'testing_db', 'localhost', 1433, [ + 'TrustServerCertificate' => 'true' + ]); + $this->mssqlConnection->setName('mssql-db'); + App::getConfig()->addOrUpdateDBConnection($this->mssqlConnection); + + // Test connection works + $db = new \WebFiori\Database\Database($this->mssqlConnection); + $db->raw("SELECT 1 AS test")->execute(); + $db->close(); + } + + private function setupMysqlConnection(): void { + $this->mysqlConnection = new ConnectionInfo('mysql', 'root', MYSQL_ROOT_PASSWORD, 'testing_db', '127.0.0.1', 3306); + $this->mysqlConnection->setName('mysql-db'); + App::getConfig()->addOrUpdateDBConnection($this->mysqlConnection); + } + + protected function setUp(): void { + parent::setUp(); + App::getConfig()->removeAllDBConnections(); + $this->setupMysqlConnection(); + $this->dropSchemaTable('mysql-db'); + $this->cleanupMigrations(); + } + + protected function tearDown(): void { + $this->cleanupMigrations(); + $this->dropSchemaTable('mysql-db'); + $this->dropSchemaTable('mssql-db'); + parent::tearDown(); + } +} diff --git a/tests/WebFiori/Framework/Tests/Cli/SkipMigrationsCommandTest.php b/tests/WebFiori/Framework/Tests/Cli/SkipMigrationsCommandTest.php index 742bc8af7..accc783cb 100644 --- a/tests/WebFiori/Framework/Tests/Cli/SkipMigrationsCommandTest.php +++ b/tests/WebFiori/Framework/Tests/Cli/SkipMigrationsCommandTest.php @@ -4,9 +4,9 @@ use WebFiori\Database\ConnectionInfo; use WebFiori\Framework\App; use WebFiori\Framework\Cli\CLITestCase; -use WebFiori\Framework\Cli\Commands\SkipMigrationsCommand; -use WebFiori\Framework\Cli\Commands\RunMigrationsCommandNew; use WebFiori\Framework\Cli\Commands\InitMigrationsCommand; +use WebFiori\Framework\Cli\Commands\RunMigrationsCommandNew; +use WebFiori\Framework\Cli\Commands\SkipMigrationsCommand; /** * Test cases for SkipMigrationsCommand. @@ -17,76 +17,107 @@ class SkipMigrationsCommandTest extends CLITestCase { /** * @test */ - public function testSkipWithNoConnections() { - App::getConfig()->removeAllDBConnections(); + public function testSkipAll() { + $this->createTestMigration('SkipAll1'); + $this->createTestMigration('SkipAll2'); + $this->initMigrations(); $output = $this->executeMultiCommand([ SkipMigrationsCommand::class, - '--all' => '' + '--all' => '', + '--connection' => 'test-connection' ]); - $this->assertEquals([ - "Info: No database connections configured.\n" - ], $output); - $this->assertEquals(1, $this->getExitCode()); + $outputStr = implode('', $output); + $this->assertStringContainsString('Skipped: App\\Database\\Migrations\\SkipAll1', $outputStr); + $this->assertStringContainsString('Skipped: App\\Database\\Migrations\\SkipAll2', $outputStr); + $this->assertStringContainsString('Total skipped: 2', $outputStr); + $this->assertEquals(0, $this->getExitCode()); } /** * @test */ - public function testSkipWithInvalidConnection() { + public function testSkipAllWhenNothingPending() { + $this->createTestMigration('NoPending'); + $this->initMigrations(); + + // Run it first + $this->executeMultiCommand([ + RunMigrationsCommandNew::class, + '--connection' => 'test-connection' + ]); + + // Skip all - nothing left $output = $this->executeMultiCommand([ SkipMigrationsCommand::class, '--all' => '', - '--connection' => 'ghost' + '--connection' => 'test-connection' ]); - $this->assertEquals([ - "Error: Connection 'ghost' not found.\n" - ], $output); - $this->assertEquals(1, $this->getExitCode()); + $outputStr = implode('', $output); + $this->assertStringContainsString('No pending migrations to skip', $outputStr); + $this->assertEquals(0, $this->getExitCode()); } /** * @test */ - public function testSkipWithNoMigrations() { + public function testSkipAlreadyApplied() { + $this->createTestMigration('AlreadyDone'); + $this->initMigrations(); + + // Run it first + $this->executeMultiCommand([ + RunMigrationsCommandNew::class, + '--connection' => 'test-connection' + ]); + + // Try to skip it $output = $this->executeMultiCommand([ SkipMigrationsCommand::class, - '--all' => '', + '--name' => 'App\\Database\\Migrations\\AlreadyDone', '--connection' => 'test-connection' ]); - $this->assertEquals([ - "Info: No migrations found.\n" - ], $output); - $this->assertEquals(0, $this->getExitCode()); + $outputStr = implode('', $output); + $this->assertStringContainsString('Could not skip', $outputStr); + $this->assertStringContainsString('not found or already applied', $outputStr); + $this->assertEquals(1, $this->getExitCode()); } /** * @test */ - public function testSkipWithNoModeFlag() { - $this->createTestMigration('NoMode1'); + public function testSkippedMigrationWontRun() { + $this->createTestMigration('WontRun'); $this->initMigrations(); - $output = $this->executeMultiCommand([ + // Skip it + $this->executeMultiCommand([ SkipMigrationsCommand::class, + '--name' => 'App\\Database\\Migrations\\WontRun', + '--connection' => 'test-connection' + ]); + + // Now run migrations + $output = $this->executeMultiCommand([ + RunMigrationsCommandNew::class, '--connection' => 'test-connection' ]); $outputStr = implode('', $output); - $this->assertStringContainsString('Provide --name, --all, or --up-to', $outputStr); - $this->assertEquals(1, $this->getExitCode()); + $this->assertStringContainsString('Skipped: App\\Database\\Migrations\\WontRun', $outputStr); + $this->assertStringNotContainsString('Applied: App\\Database\\Migrations\\WontRun', $outputStr); + $this->assertEquals(0, $this->getExitCode()); } /** * @test */ - public function testSkipAll() { - $this->createTestMigration('SkipAll1'); - $this->createTestMigration('SkipAll2'); - $this->initMigrations(); + public function testSkipSchemaTableMissing() { + $this->createTestMigration('NoTable'); + // Do NOT call initMigrations() $output = $this->executeMultiCommand([ SkipMigrationsCommand::class, @@ -95,10 +126,9 @@ public function testSkipAll() { ]); $outputStr = implode('', $output); - $this->assertStringContainsString('Skipped: App\\Database\\Migrations\\SkipAll1', $outputStr); - $this->assertStringContainsString('Skipped: App\\Database\\Migrations\\SkipAll2', $outputStr); - $this->assertStringContainsString('Total skipped: 2', $outputStr); - $this->assertEquals(0, $this->getExitCode()); + $this->assertStringContainsString('schema_changes', $outputStr); + $this->assertStringContainsString('migrations:ini', $outputStr); + $this->assertEquals(1, $this->getExitCode()); } /** @@ -142,112 +172,96 @@ public function testSkipUpTo() { /** * @test */ - public function testSkipAlreadyApplied() { - $this->createTestMigration('AlreadyDone'); - $this->initMigrations(); - - // Run it first - $this->executeMultiCommand([ - RunMigrationsCommandNew::class, - '--connection' => 'test-connection' - ]); - - // Try to skip it + public function testSkipWithInvalidConnection() { $output = $this->executeMultiCommand([ SkipMigrationsCommand::class, - '--name' => 'App\\Database\\Migrations\\AlreadyDone', - '--connection' => 'test-connection' + '--all' => '', + '--connection' => 'ghost' ]); - $outputStr = implode('', $output); - $this->assertStringContainsString('Could not skip', $outputStr); - $this->assertStringContainsString('not found or already applied', $outputStr); + $this->assertEquals([ + "Error: Connection 'ghost' not found.\n" + ], $output); $this->assertEquals(1, $this->getExitCode()); } /** * @test */ - public function testSkipAllWhenNothingPending() { - $this->createTestMigration('NoPending'); - $this->initMigrations(); - - // Run it first - $this->executeMultiCommand([ - RunMigrationsCommandNew::class, - '--connection' => 'test-connection' - ]); + public function testSkipWithNoConnections() { + App::getConfig()->removeAllDBConnections(); - // Skip all - nothing left $output = $this->executeMultiCommand([ SkipMigrationsCommand::class, - '--all' => '', - '--connection' => 'test-connection' + '--all' => '' ]); - $outputStr = implode('', $output); - $this->assertStringContainsString('No pending migrations to skip', $outputStr); - $this->assertEquals(0, $this->getExitCode()); + $this->assertEquals([ + "Info: No database connections configured.\n" + ], $output); + $this->assertEquals(1, $this->getExitCode()); } /** * @test */ - public function testSkipSchemaTableMissing() { - $this->createTestMigration('NoTable'); - // Do NOT call initMigrations() - + public function testSkipWithNoMigrations() { $output = $this->executeMultiCommand([ SkipMigrationsCommand::class, '--all' => '', '--connection' => 'test-connection' ]); - $outputStr = implode('', $output); - $this->assertStringContainsString('schema_changes', $outputStr); - $this->assertStringContainsString('migrations:ini', $outputStr); - $this->assertEquals(1, $this->getExitCode()); + $this->assertEquals([ + "Info: No migrations found.\n" + ], $output); + $this->assertEquals(0, $this->getExitCode()); } /** * @test */ - public function testSkippedMigrationWontRun() { - $this->createTestMigration('WontRun'); + public function testSkipWithNoModeFlag() { + $this->createTestMigration('NoMode1'); $this->initMigrations(); - // Skip it - $this->executeMultiCommand([ - SkipMigrationsCommand::class, - '--name' => 'App\\Database\\Migrations\\WontRun', - '--connection' => 'test-connection' - ]); - - // Now run migrations $output = $this->executeMultiCommand([ - RunMigrationsCommandNew::class, + SkipMigrationsCommand::class, '--connection' => 'test-connection' ]); $outputStr = implode('', $output); - $this->assertStringContainsString('Skipped: App\\Database\\Migrations\\WontRun', $outputStr); - $this->assertStringNotContainsString('Applied: App\\Database\\Migrations\\WontRun', $outputStr); - $this->assertEquals(0, $this->getExitCode()); + $this->assertStringContainsString('Provide --name, --all, or --up-to', $outputStr); + $this->assertEquals(1, $this->getExitCode()); } - // --- Helpers --- + private function cleanPhpFiles(string $dir): void { + if (!is_dir($dir)) { + return; + } - private function initMigrations(string $env = 'dev'): void { - $args = [ - InitMigrationsCommand::class, - '--connection' => 'test-connection' - ]; + $iterator = new \RecursiveIteratorIterator( + new \RecursiveDirectoryIterator($dir, \RecursiveDirectoryIterator::SKIP_DOTS), + \RecursiveIteratorIterator::CHILD_FIRST + ); - if ($env !== 'dev') { - $args['--env'] = $env; + foreach ($iterator as $item) { + if ($item->isFile() && $item->getExtension() === 'php') { + unlink($item->getRealPath()); + } elseif ($item->isDir() && count(scandir($item->getRealPath())) === 2) { + rmdir($item->getRealPath()); + } } + } - $this->executeMultiCommand($args); + private function cleanupMigrations(): void { + $dir = APP_PATH.'Database'.DS.'Migrations'; + $this->cleanPhpFiles($dir); + } + + private function cleanupSeeders(): void { + $dir = APP_PATH.'Database'.DS.'Seeders'; + $this->cleanPhpFiles($dir); } private function createTestMigration(string $name): void { @@ -278,38 +292,10 @@ public function down(Database \$db): void { file_put_contents($dir.DS.$name.'.php', $content); } - private function cleanupMigrations(): void { - $dir = APP_PATH.'Database'.DS.'Migrations'; - $this->cleanPhpFiles($dir); - } - - private function cleanPhpFiles(string $dir): void { - if (!is_dir($dir)) { - return; - } - - $iterator = new \RecursiveIteratorIterator( - new \RecursiveDirectoryIterator($dir, \RecursiveDirectoryIterator::SKIP_DOTS), - \RecursiveIteratorIterator::CHILD_FIRST - ); - - foreach ($iterator as $item) { - if ($item->isFile() && $item->getExtension() === 'php') { - unlink($item->getRealPath()); - } elseif ($item->isDir() && count(scandir($item->getRealPath())) === 2) { - rmdir($item->getRealPath()); - } - } - } - - private function cleanupSeeders(): void { - $dir = APP_PATH.'Database'.DS.'Seeders'; - $this->cleanPhpFiles($dir); - } - private function dropSchemaTable(): void { try { $conn = App::getConfig()->getDBConnection('test-connection'); + if ($conn !== null) { $db = new \WebFiori\Database\Database($conn); $db->raw("DROP TABLE IF EXISTS schema_changes")->execute(); @@ -320,6 +306,21 @@ private function dropSchemaTable(): void { } } + // --- Helpers --- + + private function initMigrations(string $env = 'dev'): void { + $args = [ + InitMigrationsCommand::class, + '--connection' => 'test-connection' + ]; + + if ($env !== 'dev') { + $args['--env'] = $env; + } + + $this->executeMultiCommand($args); + } + private function setupTestConnection(): void { $this->testConnection = new ConnectionInfo('mysql', 'root', MYSQL_ROOT_PASSWORD, 'testing_db', '127.0.0.1', 3306); $this->testConnection->setName('test-connection'); diff --git a/tests/WebFiori/Framework/Tests/Config/JsonDriverTest.php b/tests/WebFiori/Framework/Tests/Config/JsonDriverTest.php index 3836d93f8..c9f7243db 100644 --- a/tests/WebFiori/Framework/Tests/Config/JsonDriverTest.php +++ b/tests/WebFiori/Framework/Tests/Config/JsonDriverTest.php @@ -254,7 +254,8 @@ public function testDatabaseConnections02() { $this->assertEquals('root', $account->getUsername()); $this->assertEquals([ 'KG' => 9, - 'OP' => 'hello' + 'OP' => 'hello', + 'connection-name' => 'New_Connection' ], $account->getExtars()); $driver->removeAllDBConnections(); $this->assertEquals(0, count($driver->getDBConnections())); @@ -290,7 +291,8 @@ public function testDatabaseConnections03() { $this->assertEquals('root', $account->getUsername()); $this->assertEquals([ 'A' => 'B', - 'C' => 'D' + 'C' => 'D', + 'connection-name' => 'not_ok' ], $account->getExtars()); } /**