diff --git a/README.md b/README.md index 7292407..29d1b1d 100644 --- a/README.md +++ b/README.md @@ -340,6 +340,29 @@ $runner->createSchemaTable(); $runner->apply(); ``` + +#### Connection-Targeted Migrations + +In multi-database architectures, you can restrict a migration to specific named connections. This is useful when different databases serve different purposes (e.g., one for user data, another for reporting): + +```php +class CreateReportsTable extends AbstractMigration { + public function getTargetConnections(): array { + return ['reporting-db']; // Only runs against the 'reporting-db' connection + } + + public function up(Database $db): void { + // ... + } + + public function down(Database $db): void { + // ... + } +} +``` + +Migrations with an empty `getTargetConnections()` (the default) run on all connections. The connection name comes from `ConnectionInfo::getName()`. + ### Database Seeders Populate your database with sample data: diff --git a/WebFiori/Database/Schema/DatabaseChange.php b/WebFiori/Database/Schema/DatabaseChange.php index 1bee24f..354f325 100644 --- a/WebFiori/Database/Schema/DatabaseChange.php +++ b/WebFiori/Database/Schema/DatabaseChange.php @@ -114,6 +114,18 @@ public function getName(): string { return static::class; } + /** + * Get the target connections where this change should be executed. + * + * Override this method to restrict execution to specific named connections. + * Uses the logical connection name from ConnectionInfo::getName(). + * + * @return array Array of connection names. Empty array means all connections. + */ + public function getTargetConnections(): array { + return []; + } + /** * Get the type of database change. * diff --git a/WebFiori/Database/Schema/SchemaRunner.php b/WebFiori/Database/Schema/SchemaRunner.php index 6d77cad..50f7452 100644 --- a/WebFiori/Database/Schema/SchemaRunner.php +++ b/WebFiori/Database/Schema/SchemaRunner.php @@ -156,6 +156,12 @@ public function apply(): DatabaseChangeResult { continue; } + if (!$this->shouldRunForConnection($change)) { + $processed[$name] = true; + $result->addSkipped($change, 'Connection mismatch'); + continue; + } + if (!$this->areDependenciesSatisfied($change)) { continue; // Don't mark as processed - may be satisfied later } @@ -211,6 +217,10 @@ public function applyOne(): ?DatabaseChange { continue; } + if (!$this->shouldRunForConnection($change)) { + continue; + } + if (!$this->areDependenciesSatisfied($change)) { continue; } @@ -364,6 +374,10 @@ public function getPendingChanges(bool $withQueries = false): array { continue; } + if (!$this->shouldRunForConnection($change)) { + continue; + } + $info = ['change' => $change, 'queries' => []]; if ($withQueries) { @@ -581,6 +595,10 @@ public function skipAll(): array { continue; } + if (!$this->shouldRunForConnection($change)) { + continue; + } + $this->getRepository()->recordSkipped($change, $batch); $skipped[] = $change; } @@ -613,6 +631,10 @@ public function skipNext(int $count = 1): array { continue; } + if (!$this->shouldRunForConnection($change)) { + continue; + } + $this->getRepository()->recordSkipped($change, $batch); $skipped[] = $change; } @@ -647,6 +669,14 @@ public function skipUpTo(string $changeName): array { continue; } + if (!$this->shouldRunForConnection($change)) { + if ($change->getName() === $changeName) { + break; + } + + continue; + } + $this->getRepository()->recordSkipped($change, $batch); $skipped[] = $change; @@ -741,6 +771,18 @@ private function resolveClassName(\SplFileInfo $file, string $basePath, string $ return null; } + private function shouldRunForConnection(DatabaseChange $change): bool { + $targets = $change->getTargetConnections(); + + if (empty($targets)) { + return true; + } + + $connInfo = $this->getConnectionInfo(); + + return $connInfo !== null && in_array($connInfo->getName(), $targets); + } + private function shouldRunInEnvironment(DatabaseChange $change): bool { $environments = $change->getEnvironments(); diff --git a/examples/06-migrations/CreateReportsTableMigration.php b/examples/06-migrations/CreateReportsTableMigration.php new file mode 100644 index 0000000..c0180e3 --- /dev/null +++ b/examples/06-migrations/CreateReportsTableMigration.php @@ -0,0 +1,51 @@ +createBlueprint('daily_reports')->addColumns([ + 'id' => [ + ColOption::TYPE => DataType::INT, + ColOption::PRIMARY => true, + ColOption::AUTO_INCREMENT => true + ], + 'report-date' => [ + ColOption::TYPE => DataType::DATE, + ColOption::NULL => false + ], + 'total-orders' => [ + ColOption::TYPE => DataType::INT, + ColOption::DEFAULT => 0 + ], + 'revenue' => [ + ColOption::TYPE => DataType::DECIMAL, + ColOption::SIZE => 10 + ] + ]); + + $db->table('daily_reports')->createTable(); + $db->execute(); + } + + public function down(Database $db): void { + $db->raw("DROP TABLE IF EXISTS daily_reports")->execute(); + } +} diff --git a/examples/06-migrations/README.md b/examples/06-migrations/README.md index aec4f2f..8a2a3d2 100644 --- a/examples/06-migrations/README.md +++ b/examples/06-migrations/README.md @@ -1,33 +1,35 @@ -# Database Migrations - -This example demonstrates how to create and run database migrations using WebFiori's migration system. - -## What This Example Shows - -- Creating migration classes that extend AbstractMigration -- Implementing up() and down() methods for schema changes -- Running migrations using SchemaRunner -- Rolling back migrations - -## Files - -- [`example.php`](example.php) - Main example code -- [`CreateUsersTableMigration.php`](CreateUsersTableMigration.php) - Migration to create users table -- [`AddEmailIndexMigration.php`](AddEmailIndexMigration.php) - Migration to add email index - -## Running the Example - -```bash -php example.php -``` - -## Expected Output - -The example will create migration classes, run them to modify the database schema, and demonstrate rollback functionality. - - -## Related Examples - -- [03-table-blueprints](../03-table-blueprints/) - Define table structures -- [07-seeders](../07-seeders/) - Populate data after migrations -- [05-transactions](../05-transactions/) - Understand rollback behavior +# Database Migrations + +This example demonstrates how to create and run database migrations using WebFiori's migration system. + +## What This Example Shows + +- Creating migration classes that extend AbstractMigration +- Implementing up() and down() methods for schema changes +- Running migrations using SchemaRunner +- Rolling back migrations +- Connection-targeted migrations (restricting migrations to specific databases) + +## Files + +- [`example.php`](example.php) - Main example code +- [`CreateUsersTableMigration.php`](CreateUsersTableMigration.php) - Migration to create users table +- [`AddEmailIndexMigration.php`](AddEmailIndexMigration.php) - Migration to add email index +- [`CreateReportsTableMigration.php`](CreateReportsTableMigration.php) - Migration targeting a specific connection + +## Running the Example + +```bash +php example.php +``` + +## Expected Output + +The example will create migration classes, run them to modify the database schema, and demonstrate rollback functionality. + + +## Related Examples + +- [03-table-blueprints](../03-table-blueprints/) - Define table structures +- [07-seeders](../07-seeders/) - Populate data after migrations +- [05-transactions](../05-transactions/) - Understand rollback behavior diff --git a/examples/06-migrations/example.php b/examples/06-migrations/example.php index dadbbc4..0158d07 100644 --- a/examples/06-migrations/example.php +++ b/examples/06-migrations/example.php @@ -91,7 +91,44 @@ echo "\n"; echo SEP; - echo "7. Cleanup:\n"; + echo "7. Connection-Targeted Migrations:\n"; + echo " In multi-database setups, migrations can target specific connections.\n\n"; + + // Simulate running against 'main-app' connection (not 'reporting-db') + $mainAppConn = new ConnectionInfo('mysql', 'root', '123456', 'testing_db'); + $mainAppConn->setName('main-app'); + + $runner2 = new SchemaRunner($mainAppConn); + require_once __DIR__.'/CreateReportsTableMigration.php'; + $runner2->register('CreateReportsTableMigration'); + $runner2->createSchemaTable(); + + $result2 = $runner2->apply(); + + foreach ($result2->getSkipped() as $skipped) { + echo " ⊘ Skipped: ".$skipped['change']->getName()." (".$skipped['reason'].")\n"; + } + + // Now simulate running against 'reporting-db' connection + $reportingConn = new ConnectionInfo('mysql', 'root', '123456', 'testing_db'); + $reportingConn->setName('reporting-db'); + + $runner3 = new SchemaRunner($reportingConn); + $runner3->register('CreateReportsTableMigration'); + $runner3->createSchemaTable(); + + $result3 = $runner3->apply(); + + foreach ($result3->getApplied() as $applied) { + echo " ✓ Applied: ".$applied->getName()." (connection matches)\n"; + } + + $runner3->rollbackUpTo(null); + $runner3->dropSchemaTable(); + echo "\n"; + + echo SEP; + echo "8. Cleanup:\n"; $runner->dropSchemaTable(); echo " ✓ Schema tracking table dropped\n"; } catch (Exception $e) { diff --git a/tests/WebFiori/Tests/Database/Schema/ConnectionTargetTest.php b/tests/WebFiori/Tests/Database/Schema/ConnectionTargetTest.php new file mode 100644 index 0000000..d5c9748 --- /dev/null +++ b/tests/WebFiori/Tests/Database/Schema/ConnectionTargetTest.php @@ -0,0 +1,272 @@ +table('schema_changes')->select(['id'])->limit(1)->execute(); + } +} + +class MigrationForReportingDb extends AbstractMigration { + public function down(Database $db): void { + } + public function getTargetConnections(): array { + return ['reporting-db']; + } + + public function up(Database $db): void { + $db->table('schema_changes')->select(['id'])->limit(1)->execute(); + } +} + +class MigrationForMasterDb extends AbstractMigration { + public function down(Database $db): void { + } + public function getTargetConnections(): array { + return ['master-db']; + } + + public function up(Database $db): void { + $db->table('schema_changes')->select(['id'])->limit(1)->execute(); + } +} + +class MigrationForMultipleConnections extends AbstractMigration { + public function down(Database $db): void { + } + public function getTargetConnections(): array { + return ['reporting-db', 'master-db']; + } + + public function up(Database $db): void { + $db->table('schema_changes')->select(['id'])->limit(1)->execute(); + } +} + +class SeederForReportingDb extends AbstractSeeder { + public function getTargetConnections(): array { + return ['reporting-db']; + } + + public function run(Database $db): void { + $db->table('schema_changes')->select(['id'])->limit(1)->execute(); + } +} + +// --- Tests --- + +class ConnectionTargetTest extends TestCase { + // Test: applyOne respects connection filtering + public function testApplyOneSkipsMismatch() { + try { + $runner = $this->createRunner('master-db'); + $runner->register(MigrationForReportingDb::class); + $runner->register(MigrationForAllConnections::class); + $runner->createSchemaTable(); + + $applied = $runner->applyOne(); + // Should skip reporting-db migration and apply the all-connections one + $this->assertNotNull($applied); + $this->assertEquals(MigrationForAllConnections::class, $applied->getName()); + + $runner->rollbackUpTo(null); + $runner->dropSchemaTable(); + } catch (\PHPUnit\Framework\AssertionFailedError $ex) { + throw $ex; + } catch (\Exception $ex) { + $this->markTestSkipped('Database connection failed: '.$ex->getMessage()); + } + } + + // Test: getTargetConnections() returns declared connections + public function testDeclaredTargetConnections() { + $migration = new MigrationForReportingDb(); + $this->assertEquals(['reporting-db'], $migration->getTargetConnections()); + } + + // Test: getTargetConnections() defaults to empty array + public function testDefaultTargetConnections() { + $migration = new MigrationForAllConnections(); + $this->assertEquals([], $migration->getTargetConnections()); + } + + // Test: getPendingChanges respects connection filtering + public function testGetPendingChangesFiltered() { + try { + $runner = $this->createRunner('master-db'); + $runner->register(MigrationForAllConnections::class); + $runner->register(MigrationForReportingDb::class); + $runner->createSchemaTable(); + + $pending = $runner->getPendingChanges(); + // Only the all-connections migration should be pending + $this->assertCount(1, $pending); + $this->assertEquals(MigrationForAllConnections::class, $pending[0]['change']->getName()); + + $runner->dropSchemaTable(); + } catch (\PHPUnit\Framework\AssertionFailedError $ex) { + throw $ex; + } catch (\Exception $ex) { + $this->markTestSkipped('Database connection failed: '.$ex->getMessage()); + } + } + + // Test: Migration targeting 'reporting-db' runs when connection is 'reporting-db' + public function testMatchingConnectionRuns() { + try { + $runner = $this->createRunner('reporting-db'); + $runner->register(MigrationForReportingDb::class); + $runner->createSchemaTable(); + + $result = $runner->apply(); + $this->assertCount(1, $result->getApplied()); + + $runner->rollbackUpTo(null); + $runner->dropSchemaTable(); + } catch (\PHPUnit\Framework\AssertionFailedError $ex) { + throw $ex; + } catch (\Exception $ex) { + $this->markTestSkipped('Database connection failed: '.$ex->getMessage()); + } + } + + // Test: Migration targeting 'reporting-db' is skipped when connection is 'master-db' + public function testMismatchedConnectionSkipped() { + try { + $runner = $this->createRunner('master-db'); + $runner->register(MigrationForReportingDb::class); + $runner->createSchemaTable(); + + $result = $runner->apply(); + $this->assertCount(0, $result->getApplied()); + $this->assertCount(1, $result->getSkipped()); + + $runner->dropSchemaTable(); + } catch (\PHPUnit\Framework\AssertionFailedError $ex) { + throw $ex; + } catch (\Exception $ex) { + $this->markTestSkipped('Database connection failed: '.$ex->getMessage()); + } + } + + // Test: Mixed - some match, some don't + public function testMixedConnectionTargeting() { + try { + $runner = $this->createRunner('reporting-db'); + $runner->register(MigrationForAllConnections::class); + $runner->register(MigrationForReportingDb::class); + $runner->register(MigrationForMasterDb::class); + $runner->createSchemaTable(); + + $result = $runner->apply(); + // All-connections + reporting-db should run, master-db should be skipped + $this->assertCount(2, $result->getApplied()); + $this->assertCount(1, $result->getSkipped()); + + $runner->rollbackUpTo(null); + $runner->dropSchemaTable(); + } catch (\PHPUnit\Framework\AssertionFailedError $ex) { + throw $ex; + } catch (\Exception $ex) { + $this->markTestSkipped('Database connection failed: '.$ex->getMessage()); + } + } + + // Test: Migration targeting multiple connections runs on any of them + public function testMultipleTargetConnections() { + try { + $runner = $this->createRunner('master-db'); + $runner->register(MigrationForMultipleConnections::class); + $runner->createSchemaTable(); + + $result = $runner->apply(); + $this->assertCount(1, $result->getApplied()); + + $runner->rollbackUpTo(null); + $runner->dropSchemaTable(); + } catch (\PHPUnit\Framework\AssertionFailedError $ex) { + throw $ex; + } catch (\Exception $ex) { + $this->markTestSkipped('Database connection failed: '.$ex->getMessage()); + } + } + + // Test: Migration with no target runs on any connection + public function testNoTargetRunsOnAnyConnection() { + try { + $runner = $this->createRunner('some-random-db'); + $runner->register(MigrationForAllConnections::class); + $runner->createSchemaTable(); + + $result = $runner->apply(); + $this->assertCount(1, $result->getApplied()); + + $runner->rollbackUpTo(null); + $runner->dropSchemaTable(); + } catch (\PHPUnit\Framework\AssertionFailedError $ex) { + throw $ex; + } catch (\Exception $ex) { + $this->markTestSkipped('Database connection failed: '.$ex->getMessage()); + } + } + + // Test: Seeder respects getTargetConnections + public function testSeederConnectionFiltering() { + try { + $runner = $this->createRunner('master-db'); + $runner->register(SeederForReportingDb::class); + $runner->createSchemaTable(); + + $result = $runner->apply(); + $this->assertCount(0, $result->getApplied()); + $this->assertCount(1, $result->getSkipped()); + + $runner->dropSchemaTable(); + } catch (\PHPUnit\Framework\AssertionFailedError $ex) { + throw $ex; + } catch (\Exception $ex) { + $this->markTestSkipped('Database connection failed: '.$ex->getMessage()); + } + } + + // Test: Skipped reason is 'Connection mismatch' + public function testSkippedReason() { + try { + $runner = $this->createRunner('master-db'); + $runner->register(MigrationForReportingDb::class); + $runner->createSchemaTable(); + + $result = $runner->apply(); + $skipped = $result->getSkipped(); + $this->assertCount(1, $skipped); + $this->assertEquals('Connection mismatch', $skipped[0]['reason']); + + $runner->dropSchemaTable(); + } catch (\PHPUnit\Framework\AssertionFailedError $ex) { + throw $ex; + } catch (\Exception $ex) { + $this->markTestSkipped('Database connection failed: '.$ex->getMessage()); + } + } + + private function createRunner(string $connectionName = 'reporting-db'): SchemaRunner { + return new SchemaRunner($this->getConnectionInfo($connectionName)); + } + private function getConnectionInfo(string $name = 'reporting-db'): ConnectionInfo { + $info = new ConnectionInfo('mysql', 'root', getenv('MYSQL_ROOT_PASSWORD') ?: '123456', 'testing_db', '127.0.0.1'); + $info->setName($name); + + return $info; + } +}