diff --git a/WebFiori/Framework/Cli/Commands/DryRunMigrationsCommand.php b/WebFiori/Framework/Cli/Commands/DryRunMigrationsCommand.php index 1986ae10e..6da7cb694 100644 --- a/WebFiori/Framework/Cli/Commands/DryRunMigrationsCommand.php +++ b/WebFiori/Framework/Cli/Commands/DryRunMigrationsCommand.php @@ -1,118 +1,118 @@ -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); - - // Discover seeders - $seedersPath = APP_PATH.'Database'.DS.'Seeders'; - $seedersNs = APP_DIR.'\\Database\\Seeders'; - $count += $this->runner->discoverFromPath($seedersPath, $seedersNs); - - if ($count === 0) { - $this->info('No migrations/seeders found.'); - return 0; - } - - return $this->dryRun(); - - } catch (Throwable $e) { - $this->error('An exception was thrown.'); - $this->println('Message: ' . $e->getMessage()); - $this->println('File: ' . $e->getFile() . ':' . $e->getLine()); - return 1; - } - } - - 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 dryRun(): int { - $pending = $this->runner->getPendingChanges(true); - - if (empty($pending)) { - $this->info('No pending migrations/seeders.'); - return 0; - } - - $this->println('Pending migrations/seeders:'); - foreach ($pending as $item) { - $this->println(' - ' . $item['change']->getName()); - if (!empty($item['queries'])) { - $this->println(' Queries:'); - foreach ($item['queries'] as $query) { - $this->println(' ' . $query); - } - } else { - $this->println(' Queries:'); - $this->println(' No Queries'); - } - } - - return 0; - } -} +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); + + // Discover seeders + $seedersPath = APP_PATH.'Database'.DS.'Seeders'; + $seedersNs = APP_DIR.'\\Database\\Seeders'; + $count += $this->runner->discoverFromPath($seedersPath, $seedersNs, true); + + if ($count === 0) { + $this->info('No migrations/seeders found.'); + return 0; + } + + return $this->dryRun(); + + } catch (Throwable $e) { + $this->error('An exception was thrown.'); + $this->println('Message: ' . $e->getMessage()); + $this->println('File: ' . $e->getFile() . ':' . $e->getLine()); + return 1; + } + } + + 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 dryRun(): int { + $pending = $this->runner->getPendingChanges(true); + + if (empty($pending)) { + $this->info('No pending migrations/seeders.'); + return 0; + } + + $this->println('Pending migrations/seeders:'); + foreach ($pending as $item) { + $this->println(' - ' . $item['change']->getName()); + if (!empty($item['queries'])) { + $this->println(' Queries:'); + foreach ($item['queries'] as $query) { + $this->println(' ' . $query); + } + } else { + $this->println(' Queries:'); + $this->println(' No Queries'); + } + } + + return 0; + } +} diff --git a/WebFiori/Framework/Cli/Commands/FreshMigrationsCommand.php b/WebFiori/Framework/Cli/Commands/FreshMigrationsCommand.php index fa4222093..eb7ea93d9 100644 --- a/WebFiori/Framework/Cli/Commands/FreshMigrationsCommand.php +++ b/WebFiori/Framework/Cli/Commands/FreshMigrationsCommand.php @@ -47,11 +47,11 @@ public function exec(): int { // Discover migrations $migrationsPath = APP_PATH.'Database'.DS.'Migrations'; $namespace = APP_DIR.'\\Database\\Migrations'; - $migrationsCount = $this->runner->discoverFromPath($migrationsPath, $namespace); + $migrationsCount = $this->runner->discoverFromPath($migrationsPath, $namespace, true); $seedersPath = APP_PATH.'Database'.DS.'Seeders'; $seedersNamespace = APP_DIR.'\\Database\\Seeders'; - $seedersCount = $this->runner->discoverFromPath($seedersPath, $seedersNamespace); + $seedersCount = $this->runner->discoverFromPath($seedersPath, $seedersNamespace, true); $count = $migrationsCount + $seedersCount; diff --git a/WebFiori/Framework/Cli/Commands/MigrationsStatusCommand.php b/WebFiori/Framework/Cli/Commands/MigrationsStatusCommand.php index 49c8e7fb8..419b38ce6 100644 --- a/WebFiori/Framework/Cli/Commands/MigrationsStatusCommand.php +++ b/WebFiori/Framework/Cli/Commands/MigrationsStatusCommand.php @@ -1,119 +1,119 @@ -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); - - if ($count === 0) { - $this->info('No migrations found.'); - return 0; - } - - return $this->showStatus(); - - } catch (Throwable $e) { - $this->error('An exception was thrown.'); - $this->println('Message: ' . $e->getMessage()); - $this->println('File: ' . $e->getFile() . ':' . $e->getLine()); - return 1; - } - } - - 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 showStatus(): int { - $allChanges = $this->runner->getChanges(); - $pending = $this->runner->getPendingChanges(false); - - // Separate applied and pending - $pendingNames = array_map(fn($item) => $item['change']->getName(), $pending); - $applied = array_filter($allChanges, fn($change) => !in_array($change->getName(), $pendingNames)); - - if (!empty($applied)) { - $this->println('Applied migrations:'); - foreach ($applied as $change) { - $this->success(' - ' . $change->getName()); - } - } else { - $this->info('No applied migrations.'); - } - - $this->println(''); - - if (!empty($pending)) { - $this->println('Pending migrations:'); - foreach ($pending as $item) { - $this->warning(' - ' . $item['change']->getName()); - } - } else { - $this->info('No pending migrations.'); - } - - return 0; - } -} +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); + + if ($count === 0) { + $this->info('No migrations found.'); + return 0; + } + + return $this->showStatus(); + + } catch (Throwable $e) { + $this->error('An exception was thrown.'); + $this->println('Message: ' . $e->getMessage()); + $this->println('File: ' . $e->getFile() . ':' . $e->getLine()); + return 1; + } + } + + 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 showStatus(): int { + $allChanges = $this->runner->getChanges(); + $pending = $this->runner->getPendingChanges(false); + + // Separate applied and pending + $pendingNames = array_map(fn($item) => $item['change']->getName(), $pending); + $applied = array_filter($allChanges, fn($change) => !in_array($change->getName(), $pendingNames)); + + if (!empty($applied)) { + $this->println('Applied migrations:'); + foreach ($applied as $change) { + $this->success(' - ' . $change->getName()); + } + } else { + $this->info('No applied migrations.'); + } + + $this->println(''); + + if (!empty($pending)) { + $this->println('Pending migrations:'); + foreach ($pending as $item) { + $this->warning(' - ' . $item['change']->getName()); + } + } else { + $this->info('No pending migrations.'); + } + + return 0; + } +} diff --git a/WebFiori/Framework/Cli/Commands/RollbackMigrationsCommand.php b/WebFiori/Framework/Cli/Commands/RollbackMigrationsCommand.php index 087b24db9..724d99bbe 100644 --- a/WebFiori/Framework/Cli/Commands/RollbackMigrationsCommand.php +++ b/WebFiori/Framework/Cli/Commands/RollbackMigrationsCommand.php @@ -1,115 +1,115 @@ -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'; - $this->runner->discoverFromPath($migrationsPath, $namespace); - - return $this->rollback(); - - } catch (Throwable $e) { - $this->error('An exception was thrown.'); - $this->println('Message: ' . $e->getMessage()); - $this->println('File: ' . $e->getFile() . ':' . $e->getLine()); - return 1; - } - } - - 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 rollback(): int { - try { - if ($this->isArgProvided('--all')) { - $this->println('Rolling back all migrations...'); - $rolled = $this->runner->rollbackUpTo(null); - } else if ($this->isArgProvided('--batch')) { - $batch = (int)$this->getArgValue('--batch'); - $this->println("Rolling back batch $batch..."); - $rolled = $this->runner->rollbackBatch($batch); - } else { - $this->println('Rolling back last batch...'); - $rolled = $this->runner->rollbackLastBatch(); - } - - if (empty($rolled)) { - $this->info('No migrations to rollback.'); - } else { - foreach ($rolled as $change) { - $this->success('Rolled back: ' . $change->getName()); - } - $this->info('Total rolled back: ' . count($rolled)); - } - - return 0; - } catch (Throwable $e) { - $this->error('Rollback failed: ' . $e->getMessage()); - return 1; - } - } -} +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'; + $this->runner->discoverFromPath($migrationsPath, $namespace, true); + + return $this->rollback(); + + } catch (Throwable $e) { + $this->error('An exception was thrown.'); + $this->println('Message: ' . $e->getMessage()); + $this->println('File: ' . $e->getFile() . ':' . $e->getLine()); + return 1; + } + } + + 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 rollback(): int { + try { + if ($this->isArgProvided('--all')) { + $this->println('Rolling back all migrations...'); + $rolled = $this->runner->rollbackUpTo(null); + } else if ($this->isArgProvided('--batch')) { + $batch = (int)$this->getArgValue('--batch'); + $this->println("Rolling back batch $batch..."); + $rolled = $this->runner->rollbackBatch($batch); + } else { + $this->println('Rolling back last batch...'); + $rolled = $this->runner->rollbackLastBatch(); + } + + if (empty($rolled)) { + $this->info('No migrations to rollback.'); + } else { + foreach ($rolled as $change) { + $this->success('Rolled back: ' . $change->getName()); + } + $this->info('Total rolled back: ' . count($rolled)); + } + + return 0; + } catch (Throwable $e) { + $this->error('Rollback failed: ' . $e->getMessage()); + return 1; + } + } +} diff --git a/WebFiori/Framework/Cli/Commands/RunMigrationsCommandNew.php b/WebFiori/Framework/Cli/Commands/RunMigrationsCommandNew.php index 503b234d8..2e333fecf 100644 --- a/WebFiori/Framework/Cli/Commands/RunMigrationsCommandNew.php +++ b/WebFiori/Framework/Cli/Commands/RunMigrationsCommandNew.php @@ -47,11 +47,11 @@ public function exec(): int { // Discover migrations $migrationsPath = APP_PATH.'Database'.DS.'Migrations'; $namespace = APP_DIR.'\\Database\\Migrations'; - $count = $this->runner->discoverFromPath($migrationsPath, $namespace); + $count = $this->runner->discoverFromPath($migrationsPath, $namespace, true); $seedersPath = APP_PATH.'Database'.DS.'Seeders'; $seedersNamespace = APP_DIR.'\\Database\\Seeders'; - $count += $this->runner->discoverFromPath($seedersPath, $seedersNamespace); + $count += $this->runner->discoverFromPath($seedersPath, $seedersNamespace, true); if ($count === 0) { $this->info('No migrations found.'); diff --git a/tests/WebFiori/Framework/Tests/Cli/DryRunMigrationsCommandTest.php b/tests/WebFiori/Framework/Tests/Cli/DryRunMigrationsCommandTest.php index 566513215..e991ebc77 100644 --- a/tests/WebFiori/Framework/Tests/Cli/DryRunMigrationsCommandTest.php +++ b/tests/WebFiori/Framework/Tests/Cli/DryRunMigrationsCommandTest.php @@ -194,12 +194,26 @@ public function down(Database \$db): void { private function cleanupMigrations(): void { $dir = APP_PATH.'Database'.DS.'Migrations'; + $this->cleanPhpFiles($dir); + $seedersDir = APP_PATH.'Database'.DS.'Seeders'; + $this->cleanPhpFiles($seedersDir); + } + + private function cleanPhpFiles(string $dir): void { + if (!is_dir($dir)) { + return; + } + + $iterator = new \RecursiveIteratorIterator( + new \RecursiveDirectoryIterator($dir, \RecursiveDirectoryIterator::SKIP_DOTS), + \RecursiveIteratorIterator::CHILD_FIRST + ); - if (is_dir($dir)) { - foreach (glob($dir.DS.'*.php') as $file) { - if (basename($file) !== '.gitkeep') { - unlink($file); - } + foreach ($iterator as $item) { + if ($item->isFile() && $item->getExtension() === 'php') { + unlink($item->getRealPath()); + } elseif ($item->isDir() && count(scandir($item->getRealPath())) === 2) { + rmdir($item->getRealPath()); } } } diff --git a/tests/WebFiori/Framework/Tests/Cli/FreshMigrationsCommandTest.php b/tests/WebFiori/Framework/Tests/Cli/FreshMigrationsCommandTest.php index 59d2ee284..305b50f68 100644 --- a/tests/WebFiori/Framework/Tests/Cli/FreshMigrationsCommandTest.php +++ b/tests/WebFiori/Framework/Tests/Cli/FreshMigrationsCommandTest.php @@ -182,12 +182,26 @@ public function down(Database \$db): void { private function cleanupMigrations(): void { $dir = APP_PATH.'Database'.DS.'Migrations'; + $this->cleanPhpFiles($dir); + $seedersDir = APP_PATH.'Database'.DS.'Seeders'; + $this->cleanPhpFiles($seedersDir); + } + + private function cleanPhpFiles(string $dir): void { + if (!is_dir($dir)) { + return; + } + + $iterator = new \RecursiveIteratorIterator( + new \RecursiveDirectoryIterator($dir, \RecursiveDirectoryIterator::SKIP_DOTS), + \RecursiveIteratorIterator::CHILD_FIRST + ); - if (is_dir($dir)) { - foreach (glob($dir.DS.'*.php') as $file) { - if (basename($file) !== '.gitkeep') { - unlink($file); - } + foreach ($iterator as $item) { + if ($item->isFile() && $item->getExtension() === 'php') { + unlink($item->getRealPath()); + } elseif ($item->isDir() && count(scandir($item->getRealPath())) === 2) { + rmdir($item->getRealPath()); } } } diff --git a/tests/WebFiori/Framework/Tests/Cli/MigrationsStatusCommandTest.php b/tests/WebFiori/Framework/Tests/Cli/MigrationsStatusCommandTest.php index 5e04b7e4c..789a238e7 100644 --- a/tests/WebFiori/Framework/Tests/Cli/MigrationsStatusCommandTest.php +++ b/tests/WebFiori/Framework/Tests/Cli/MigrationsStatusCommandTest.php @@ -181,12 +181,24 @@ public function down(Database \$db): void { 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 + ); - if (is_dir($dir)) { - foreach (glob($dir.DS.'*.php') as $file) { - if (basename($file) !== '.gitkeep') { - unlink($file); - } + foreach ($iterator as $item) { + if ($item->isFile() && $item->getExtension() === 'php') { + unlink($item->getRealPath()); + } elseif ($item->isDir() && count(scandir($item->getRealPath())) === 2) { + rmdir($item->getRealPath()); } } } diff --git a/tests/WebFiori/Framework/Tests/Cli/RecursiveMigrationDiscoveryTest.php b/tests/WebFiori/Framework/Tests/Cli/RecursiveMigrationDiscoveryTest.php new file mode 100644 index 000000000..d9a6fb650 --- /dev/null +++ b/tests/WebFiori/Framework/Tests/Cli/RecursiveMigrationDiscoveryTest.php @@ -0,0 +1,444 @@ +createSubdirMigration('Master', 'CreateUsersTable'); + $this->createSubdirMigration('Lms', 'CreateCoursesTable'); + $this->initMigrations(); + + $output = $this->executeMultiCommand([ + RunMigrationsCommandNew::class, + '--connection' => 'test-connection' + ]); + + $outputStr = implode('', $output); + $this->assertStringContainsString('Running migrations...', $outputStr); + $this->assertStringContainsString('CreateUsersTable', $outputStr); + $this->assertStringContainsString('CreateCoursesTable', $outputStr); + $this->assertStringContainsString('Info: Applied: 2 migration(s)', $outputStr); + $this->assertEquals(0, $this->getExitCode()); + } + + /** + * @test + * Verify that migrations:run discovers seeders in subdirectories. + */ + public function testRunDiscoversSeedersInSubdirectories() { + $this->createSubdirSeeder('Master', 'UsersSeeder'); + $this->initMigrations(); + + $output = $this->executeMultiCommand([ + RunMigrationsCommandNew::class, + '--connection' => 'test-connection' + ]); + + $outputStr = implode('', $output); + $this->assertStringContainsString('UsersSeeder', $outputStr); + $this->assertStringContainsString('Info: Applied: 1 seeder(s)', $outputStr); + $this->assertEquals(0, $this->getExitCode()); + } + + /** + * @test + * Verify that migrations:fresh discovers migrations in subdirectories. + */ + public function testFreshDiscoversMigrationsInSubdirectories() { + $this->createSubdirMigration('Master', 'FreshSubdirMigration'); + $this->initMigrations(); + + $output = $this->executeMultiCommand([ + FreshMigrationsCommand::class, + '--connection' => 'test-connection' + ]); + + $outputStr = implode('', $output); + $this->assertStringContainsString('FreshSubdirMigration', $outputStr); + $this->assertStringContainsString('Info: Applied: 1 migration(s)', $outputStr); + $this->assertEquals(0, $this->getExitCode()); + } + + /** + * @test + * Verify that migrations:rollback discovers migrations in subdirectories. + */ + public function testRollbackDiscoversMigrationsInSubdirectories() { + $this->createSubdirMigration('Master', 'RollbackSubdirMigration'); + $this->initMigrations(); + + // First apply + $this->executeMultiCommand([ + RunMigrationsCommandNew::class, + '--connection' => 'test-connection' + ]); + + // Then rollback + $output = $this->executeMultiCommand([ + RollbackMigrationsCommand::class, + '--connection' => 'test-connection' + ]); + + $outputStr = implode('', $output); + $this->assertStringContainsString('Rolled back: ', $outputStr); + $this->assertStringContainsString('RollbackSubdirMigration', $outputStr); + $this->assertEquals(0, $this->getExitCode()); + } + + /** + * @test + * Verify that migrations:dry-run discovers migrations in subdirectories. + */ + public function testDryRunDiscoversMigrationsInSubdirectories() { + $this->createSubdirMigration('Sustainability', 'DryRunSubdirMigration'); + $this->initMigrations(); + + $output = $this->executeMultiCommand([ + DryRunMigrationsCommand::class, + '--connection' => 'test-connection' + ]); + + $outputStr = implode('', $output); + $this->assertStringContainsString('Pending migrations/seeders:', $outputStr); + $this->assertStringContainsString('DryRunSubdirMigration', $outputStr); + $this->assertEquals(0, $this->getExitCode()); + } + + /** + * @test + * Verify that migrations:status discovers migrations in subdirectories. + */ + public function testStatusDiscoversMigrationsInSubdirectories() { + $this->createSubdirMigration('Lms', 'StatusSubdirMigration'); + $this->initMigrations(); + + $output = $this->executeMultiCommand([ + MigrationsStatusCommand::class, + '--connection' => 'test-connection' + ]); + + $outputStr = implode('', $output); + $this->assertStringContainsString('Pending migrations:', $outputStr); + $this->assertStringContainsString('StatusSubdirMigration', $outputStr); + $this->assertEquals(0, $this->getExitCode()); + } + + /** + * @test + * Verify that deeply nested subdirectories are also discovered. + */ + public function testRunDiscoversDeeplyNestedMigrations() { + $this->createDeepSubdirMigration('Master', 'V1', 'DeepNestedMigration'); + $this->initMigrations(); + + $output = $this->executeMultiCommand([ + RunMigrationsCommandNew::class, + '--connection' => 'test-connection' + ]); + + $outputStr = implode('', $output); + $this->assertStringContainsString('DeepNestedMigration', $outputStr); + $this->assertStringContainsString('Info: Applied: 1 migration(s)', $outputStr); + $this->assertEquals(0, $this->getExitCode()); + } + + /** + * @test + * Verify that a mix of top-level and subdirectory migrations are all discovered. + */ + public function testRunDiscoversMixedTopLevelAndSubdirectory() { + $this->createTopLevelMigration('TopLevelMigration'); + $this->createSubdirMigration('Master', 'SubdirMigration'); + $this->initMigrations(); + + $output = $this->executeMultiCommand([ + RunMigrationsCommandNew::class, + '--connection' => 'test-connection' + ]); + + $outputStr = implode('', $output); + $this->assertStringContainsString('TopLevelMigration', $outputStr); + $this->assertStringContainsString('SubdirMigration', $outputStr); + $this->assertStringContainsString('Info: Applied: 2 migration(s)', $outputStr); + $this->assertEquals(0, $this->getExitCode()); + } + + /** + * @test + * Verify that migrations:dry-run discovers seeders in subdirectories. + */ + public function testDryRunDiscoversSeedersInSubdirectories() { + $this->createSubdirSeeder('Master', 'DryRunSubdirSeeder'); + $this->initMigrations(); + + $output = $this->executeMultiCommand([ + DryRunMigrationsCommand::class, + '--connection' => 'test-connection' + ]); + + $outputStr = implode('', $output); + $this->assertStringContainsString('Pending migrations/seeders:', $outputStr); + $this->assertStringContainsString('DryRunSubdirSeeder', $outputStr); + $this->assertEquals(0, $this->getExitCode()); + } + + private function initMigrations(): void { + $this->executeMultiCommand([ + 'WebFiori\\Framework\\Cli\\Commands\\InitMigrationsCommand', + '--connection' => 'test-connection' + ]); + } + + private function createSubdirMigration(string $subdir, string $name): void { + $dir = APP_PATH.'Database'.DS.'Migrations'.DS.$subdir; + + if (!is_dir($dir)) { + mkdir($dir, 0755, true); + $this->createdPaths[] = $dir; + } + + $filePath = $dir.DS.$name.'.php'; + + $content = <<createdPaths[] = $filePath; + } + + private function createDeepSubdirMigration(string $subdir, string $nestedDir, string $name): void { + $dir = APP_PATH.'Database'.DS.'Migrations'.DS.$subdir.DS.$nestedDir; + + if (!is_dir($dir)) { + mkdir($dir, 0755, true); + $this->createdPaths[] = $dir; + // Also track parent if it was created + $parentDir = APP_PATH.'Database'.DS.'Migrations'.DS.$subdir; + if (!in_array($parentDir, $this->createdPaths)) { + $this->createdPaths[] = $parentDir; + } + } + + $filePath = $dir.DS.$name.'.php'; + + $content = <<createdPaths[] = $filePath; + } + + private function createTopLevelMigration(string $name): void { + $dir = APP_PATH.'Database'.DS.'Migrations'; + + if (!is_dir($dir)) { + mkdir($dir, 0755, true); + } + + $filePath = $dir.DS.$name.'.php'; + + $content = <<createdPaths[] = $filePath; + } + + private function createSubdirSeeder(string $subdir, string $name): void { + $dir = APP_PATH.'Database'.DS.'Seeders'.DS.$subdir; + + if (!is_dir($dir)) { + mkdir($dir, 0755, true); + $this->createdPaths[] = $dir; + } + + $filePath = $dir.DS.$name.'.php'; + + $content = <<createdPaths[] = $filePath; + } + + private function cleanup(): void { + // Remove files first, then directories (reverse order) + $files = []; + $dirs = []; + + foreach ($this->createdPaths as $path) { + if (is_file($path)) { + $files[] = $path; + } else { + $dirs[] = $path; + } + } + + foreach ($files as $file) { + if (is_file($file)) { + unlink($file); + } + } + + // Sort dirs by depth (deepest first) to avoid non-empty dir errors + usort($dirs, function ($a, $b) { + return substr_count($b, DS) - substr_count($a, DS); + }); + + foreach ($dirs as $dir) { + if (is_dir($dir) && $this->isDirEmpty($dir)) { + rmdir($dir); + } + } + + $this->createdPaths = []; + } + + private function isDirEmpty(string $dir): bool { + $items = scandir($dir); + return count($items) === 2; // only . and .. + } + + 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); + } + + private function dropSchemaTable(): void { + try { + $connection = App::getConfig()->getDBConnection('test-connection'); + if ($connection !== null) { + $runner = new \WebFiori\Database\Schema\SchemaRunner($connection); + $runner->dropSchemaTable(); + } + } catch (\Throwable $e) { + // Ignore errors during cleanup + } + } + + protected function setUp(): void { + parent::setUp(); + $this->createdPaths = []; + $this->setupTestConnection(); + $this->dropSchemaTable(); + $this->cleanStrayFiles(); + } + + protected function tearDown(): void { + $this->cleanup(); + $this->dropSchemaTable(); + App::getConfig()->removeAllDBConnections(); + parent::tearDown(); + } + + /** + * Remove any .php files left by other tests in Migrations/Seeders directories + * (excluding .gitkeep). This ensures test isolation when recursive discovery is enabled. + */ + private function cleanStrayFiles(): void { + $dirs = [ + APP_PATH.'Database'.DS.'Migrations', + APP_PATH.'Database'.DS.'Seeders', + ]; + + foreach ($dirs as $dir) { + if (!is_dir($dir)) { + continue; + } + + $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()) { + // Remove empty subdirs (but not the base dir) + $contents = scandir($item->getRealPath()); + if (count($contents) === 2) { // only . and .. + rmdir($item->getRealPath()); + } + } + } + } + } +} diff --git a/tests/WebFiori/Framework/Tests/Cli/RollbackMigrationsCommandTest.php b/tests/WebFiori/Framework/Tests/Cli/RollbackMigrationsCommandTest.php index 01c56159b..4807b3ea9 100644 --- a/tests/WebFiori/Framework/Tests/Cli/RollbackMigrationsCommandTest.php +++ b/tests/WebFiori/Framework/Tests/Cli/RollbackMigrationsCommandTest.php @@ -228,12 +228,24 @@ public function down(Database \$db): void { private function cleanupMigrations(): void { $dir = APP_PATH.'Database'.DS.'Migrations'; + $this->cleanPhpFiles($dir); + } + + private function cleanPhpFiles(string $dir): void { + if (!is_dir($dir)) { + return; + } - if (is_dir($dir)) { - foreach (glob($dir.DS.'*.php') as $file) { - if (basename($file) !== '.gitkeep') { - unlink($file); - } + $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()); } } } @@ -248,11 +260,33 @@ protected function setUp(): void { parent::setUp(); $this->setupTestConnection(); $this->cleanupMigrations(); + $this->dropSchemaTable(); + $this->initSchemaTable(); } protected function tearDown(): void { $this->cleanupMigrations(); + $this->dropSchemaTable(); App::getConfig()->removeAllDBConnections(); parent::tearDown(); } + + private function dropSchemaTable(): void { + try { + $connection = App::getConfig()->getDBConnection('test-connection'); + if ($connection !== null) { + $runner = new \WebFiori\Database\Schema\SchemaRunner($connection); + $runner->dropSchemaTable(); + } + } catch (\Throwable $e) { + // Ignore + } + } + + private function initSchemaTable(): void { + $this->executeMultiCommand([ + 'WebFiori\\Framework\\Cli\\Commands\\InitMigrationsCommand', + '--connection' => 'test-connection' + ]); + } } diff --git a/tests/WebFiori/Framework/Tests/Cli/RunMigrationsCommandNewTest.php b/tests/WebFiori/Framework/Tests/Cli/RunMigrationsCommandNewTest.php index 101d673d2..9e216f99b 100644 --- a/tests/WebFiori/Framework/Tests/Cli/RunMigrationsCommandNewTest.php +++ b/tests/WebFiori/Framework/Tests/Cli/RunMigrationsCommandNewTest.php @@ -169,12 +169,24 @@ public function down(Database \$db): void { private function cleanupMigrations(): void { $dir = APP_PATH.'Database'.DS.'Migrations'; + $this->cleanPhpFiles($dir); + } + + private function cleanPhpFiles(string $dir): void { + if (!is_dir($dir)) { + return; + } - if (is_dir($dir)) { - foreach (glob($dir.DS.'*.php') as $file) { - if (basename($file) !== '.gitkeep') { - unlink($file); - } + $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()); } } } @@ -210,7 +222,7 @@ private function dropSchemaTable(): void { } catch (\Throwable $e) { // Ignore errors during cleanup } - } + } /** @@ -367,10 +379,6 @@ public function rollback(Database \$db): void {} private function cleanupSeeders(): void { $dir = APP_PATH.'Database'.DS.'Seeders'; - if (is_dir($dir)) { - foreach (glob($dir.DS.'*.php') as $file) { - unlink($file); - } - } + $this->cleanPhpFiles($dir); } } diff --git a/App/Database/Migrations/EmptyRunner/XRunner.php b/tests/fixtures/Database/Migrations/EmptyRunner/XRunner.php similarity index 96% rename from App/Database/Migrations/EmptyRunner/XRunner.php rename to tests/fixtures/Database/Migrations/EmptyRunner/XRunner.php index da684d498..cec821db2 100644 --- a/App/Database/Migrations/EmptyRunner/XRunner.php +++ b/tests/fixtures/Database/Migrations/EmptyRunner/XRunner.php @@ -1,13 +1,13 @@ -getDBConnection('default-conn'); - parent::__construct(null); - } -} +getDBConnection('default-conn'); + parent::__construct(null); + } +} diff --git a/App/Database/Migrations/Multi/Migration000.php b/tests/fixtures/Database/Migrations/Multi/Migration000.php similarity index 100% rename from App/Database/Migrations/Multi/Migration000.php rename to tests/fixtures/Database/Migrations/Multi/Migration000.php diff --git a/App/Database/Migrations/Multi/Migration001.php b/tests/fixtures/Database/Migrations/Multi/Migration001.php similarity index 100% rename from App/Database/Migrations/Multi/Migration001.php rename to tests/fixtures/Database/Migrations/Multi/Migration001.php diff --git a/App/Database/Migrations/Multi/Migration002.php b/tests/fixtures/Database/Migrations/Multi/Migration002.php similarity index 100% rename from App/Database/Migrations/Multi/Migration002.php rename to tests/fixtures/Database/Migrations/Multi/Migration002.php diff --git a/App/Database/Migrations/Multi/MultiRunner.php b/tests/fixtures/Database/Migrations/Multi/MultiRunner.php similarity index 100% rename from App/Database/Migrations/Multi/MultiRunner.php rename to tests/fixtures/Database/Migrations/Multi/MultiRunner.php diff --git a/App/Database/Migrations/MultiDownErr/Migration000.php b/tests/fixtures/Database/Migrations/MultiDownErr/Migration000.php similarity index 100% rename from App/Database/Migrations/MultiDownErr/Migration000.php rename to tests/fixtures/Database/Migrations/MultiDownErr/Migration000.php diff --git a/App/Database/Migrations/MultiDownErr/Migration001.php b/tests/fixtures/Database/Migrations/MultiDownErr/Migration001.php similarity index 100% rename from App/Database/Migrations/MultiDownErr/Migration001.php rename to tests/fixtures/Database/Migrations/MultiDownErr/Migration001.php diff --git a/App/Database/Migrations/MultiDownErr/Migration002.php b/tests/fixtures/Database/Migrations/MultiDownErr/Migration002.php similarity index 100% rename from App/Database/Migrations/MultiDownErr/Migration002.php rename to tests/fixtures/Database/Migrations/MultiDownErr/Migration002.php diff --git a/App/Database/Migrations/MultiDownErr/MultiErrRunner.php b/tests/fixtures/Database/Migrations/MultiDownErr/MultiErrRunner.php similarity index 100% rename from App/Database/Migrations/MultiDownErr/MultiErrRunner.php rename to tests/fixtures/Database/Migrations/MultiDownErr/MultiErrRunner.php diff --git a/App/Database/Migrations/MultiErr/Migration000.php b/tests/fixtures/Database/Migrations/MultiErr/Migration000.php similarity index 96% rename from App/Database/Migrations/MultiErr/Migration000.php rename to tests/fixtures/Database/Migrations/MultiErr/Migration000.php index 6e024ac4e..e665209c0 100644 --- a/App/Database/Migrations/MultiErr/Migration000.php +++ b/tests/fixtures/Database/Migrations/MultiErr/Migration000.php @@ -1,32 +1,32 @@ -y(); - } - /** - * Performs the action that will apply the migration. - * - * @param Database $schema The database at which the migration will be applied to. - */ - public function up(Database $schema) { - $this->x(); - } -} +y(); + } + /** + * Performs the action that will apply the migration. + * + * @param Database $schema The database at which the migration will be applied to. + */ + public function up(Database $schema) { + $this->x(); + } +} diff --git a/App/Database/Migrations/MultiErr/Migration001.php b/tests/fixtures/Database/Migrations/MultiErr/Migration001.php similarity index 96% rename from App/Database/Migrations/MultiErr/Migration001.php rename to tests/fixtures/Database/Migrations/MultiErr/Migration001.php index 2ca424b04..843969187 100644 --- a/App/Database/Migrations/MultiErr/Migration001.php +++ b/tests/fixtures/Database/Migrations/MultiErr/Migration001.php @@ -1,32 +1,32 @@ - 'true' - ]); - parent::__construct($conn); - } -} + 'true' + ]); + parent::__construct($conn); + } +} diff --git a/App/Database/Migrations/NoConn/Migration000.php b/tests/fixtures/Database/Migrations/NoConn/Migration000.php similarity index 96% rename from App/Database/Migrations/NoConn/Migration000.php rename to tests/fixtures/Database/Migrations/NoConn/Migration000.php index 49ffc83bc..d184119c2 100644 --- a/App/Database/Migrations/NoConn/Migration000.php +++ b/tests/fixtures/Database/Migrations/NoConn/Migration000.php @@ -1,32 +1,32 @@ -y(); - } - /** - * Performs the action that will apply the migration. - * - * @param Database $schema The database at which the migration will be applied to. - */ - public function up(Database $schema) { - $this->x(); - } -} +y(); + } + /** + * Performs the action that will apply the migration. + * + * @param Database $schema The database at which the migration will be applied to. + */ + public function up(Database $schema) { + $this->x(); + } +} diff --git a/App/Database/Migrations/NoConn/XRunner.php b/tests/fixtures/Database/Migrations/NoConn/XRunner.php similarity index 95% rename from App/Database/Migrations/NoConn/XRunner.php rename to tests/fixtures/Database/Migrations/NoConn/XRunner.php index ffbaa574f..ef2909f7a 100644 --- a/App/Database/Migrations/NoConn/XRunner.php +++ b/tests/fixtures/Database/Migrations/NoConn/XRunner.php @@ -1,11 +1,11 @@ -