Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
2 changes: 1 addition & 1 deletion .gitattributes
Original file line number Diff line number Diff line change
Expand Up @@ -7,5 +7,5 @@
/php_cs.php.dist export-ignore
/phpunit.xml export-ignore
/release-please-config.json export-ignore
/examples
/examples export-ignore
/sonar-project.properties export-ignore
44 changes: 40 additions & 4 deletions README.md
Original file line number Diff line number Diff line change
Expand Up @@ -299,17 +299,36 @@ class CreateUsersTable extends AbstractMigration {

$db->createTables();
$db->execute();

}

public function down(Database $db): void {
$db->setQuery("DROP TABLE users");
$db->execute();

}
}
```

To run migrations, use the SchemaRunner:

```php
use WebFiori\Database\Schema\SchemaRunner;

$runner = new SchemaRunner($connectionInfo);

// Register migration classes
$runner->register('CreateUsersTable');
$runner->register('AddEmailIndex');

// Create schema tracking table
$runner->createSchemaTable();

// Apply all pending migrations
$appliedMigrations = $runner->apply();

// Rollback migrations
$rolledBackMigrations = $runner->rollbackUpTo(null);
```

### Database Seeders

Seeders allow you to populate your database with sample or default data.
Expand All @@ -320,7 +339,7 @@ use WebFiori\Database\Database;

class UsersSeeder extends AbstractSeeder {

public function run(Database $db): bool {
public function run(Database $db): void {
$db->table('users')->insert([
'name' => 'Administrator',
'email' => 'admin@example.com'
Expand All @@ -330,11 +349,28 @@ class UsersSeeder extends AbstractSeeder {
'name' => 'John Doe',
'email' => 'john@example.com'
])->execute();

}
}
```

To run seeders, use the same SchemaRunner:

```php
use WebFiori\Database\Schema\SchemaRunner;

$runner = new SchemaRunner($connectionInfo);

// Register seeder classes
$runner->register('UsersSeeder');
$runner->register('CategoriesSeeder');

// Create schema tracking table
$runner->createSchemaTable();

// Apply all pending seeders
$appliedSeeders = $runner->apply();
```

### Performance Monitoring

The library includes built-in performance monitoring to help you identify slow queries and optimize database performance.
Expand Down
19 changes: 19 additions & 0 deletions WebFiori/Database/Schema/DatabaseChange.php
Original file line number Diff line number Diff line change
Expand Up @@ -22,6 +22,7 @@
abstract class DatabaseChange {
private $appliedAt;
private $id;
private ?Database $database = null;

/**
* Initialize a new database change with optional name and order.
Expand Down Expand Up @@ -122,4 +123,22 @@ public function setAppliedAt(string $date) {
public function setId(int $id) {
$this->id = $id;
}

/**
* Set the database instance for this change.
*
* @param Database $db The database instance to use for this change.
*/
public function setDatabase(Database $db): void {
$this->database = $db;
}

/**
* Get the database instance for this change.
*
* @return Database|null The database instance or null if not set.
*/
public function getDatabase(): ?Database {
return $this->database;
}
}
157 changes: 74 additions & 83 deletions WebFiori/Database/Schema/SchemaRunner.php
Original file line number Diff line number Diff line change
Expand Up @@ -3,6 +3,7 @@

use Error;
use Exception;
use ReflectionClass;
use WebFiori\Database\ColOption;
use WebFiori\Database\ConnectionInfo;
use WebFiori\Database\Database;
Expand Down Expand Up @@ -47,22 +48,16 @@
class SchemaRunner extends Database {
private $dbChanges;
private $environment;
private $ns;
private $onErrCallbacks;
private $onRegErrCallbacks;
private $path;
/**
* Initialize a new schema runner with configuration.
*
* @param string $path Filesystem path where migration/seeder classes are located.
* @param string $ns PHP namespace for migration/seeder classes (e.g., 'App\\Migrations').
* @param ConnectionInfo|null $connectionInfo Database connection information.
* @param string $environment Target environment (dev, test, prod) - affects which changes run.
*/
public function __construct(string $path, string $ns, ?ConnectionInfo $connectionInfo, string $environment = 'dev') {
public function __construct(?ConnectionInfo $connectionInfo, string $environment = 'dev') {
parent::__construct($connectionInfo);
$this->path = $path;
$this->ns = $ns;
$this->environment = $environment;
$dbType = $connectionInfo !== null ? $connectionInfo->getDatabaseType() : 'mysql';
$this->onErrCallbacks = [];
Expand All @@ -77,22 +72,26 @@ public function __construct(string $path, string $ns, ?ConnectionInfo $connectio
],
'change_name' => [
ColOption::TYPE => DataType::VARCHAR,
ColOption::SIZE => 125,
ColOption::SIZE => 255,
ColOption::COMMENT => 'The name of the change.'
],
'type' => [
ColOption::TYPE => DataType::VARCHAR,
ColOption::SIZE => 20,
ColOption::COMMENT => 'The type of the change (migration, seeder, etc.).'
],
'db-name' => [
ColOption::TYPE => DataType::VARCHAR,
ColOption::SIZE => 255,
ColOption::COMMENT => 'The name of the database at which the migration was applied to.'
],
'applied-on' => [
ColOption::TYPE => $dbType == ConnectionInfo::SUPPORTED_DATABASES[1] ? DataType::DATETIME2 : DataType::DATETIME,
ColOption::COMMENT => 'The date and time at which the change was applied.'
]
]);

$this->dbChanges = [];
$this->scanPathForChanges();
}
/**
* Register a callback to handle execution errors.
Expand All @@ -117,7 +116,7 @@ public function addOnRegisterErrorCallback(callable $callback): void {
$this->onRegErrCallbacks[] = $callback;

if (empty($this->dbChanges)) {
$this->scanPathForChanges();
// No changes registered
}
}

Expand Down Expand Up @@ -149,13 +148,16 @@ public function apply(): array {
}

try {
$change->execute($this);
$this->table('schema_changes')
->insert([
'change_name' => $change->getName(),
'type' => $change->getType(),
'applied-on' => date('Y-m-d H:i:s')
])->execute();
$this->transaction(function($db) use ($change) {
$change->execute($db);
$db->table('schema_changes')
->insert([
'change_name' => $change->getName(),
'type' => $change->getType(),
'applied-on' => date('Y-m-d H:i:s'),
'db-name' => $db->getConnectionInfo()->getDatabase()
])->execute();
});

$applied[] = $change;
$appliedInPass = true;
Expand Down Expand Up @@ -192,13 +194,17 @@ public function applyOne(): ?DatabaseChange {
continue;
}

$change->execute($this);
$this->table('schema_changes')
->insert([
'change_name' => $change->getName(),
'type' => $change->getType(),
'applied-on' => date('Y-m-d H:i:s')
])->execute();
$this->transaction(function($db) use ($change) {
$migrationDb = $change->getDatabase() ?? $db;
$change->execute($migrationDb);
$db->table('schema_changes')
->insert([
'change_name' => $change->getName(),
'type' => $change->getType(),
'applied-on' => date('Y-m-d H:i:s'),
'db-name' => $db->getConnectionInfo()->getDatabase()
])->execute();
});

return $change;
}
Expand Down Expand Up @@ -269,24 +275,6 @@ public function getEnvironment(): string {
return $this->environment;
}

/**
* Get the PHP namespace used for migration and seeder classes.
*
* @return string The namespace prefix for all change classes.
*/
public function getNamespace(): string {
return $this->ns;
}

/**
* Get the filesystem path where migration and seeder classes are located.
*
* @return string The directory path containing change class files.
*/
public function getPath(): string {
return $this->path;
}

/**
* Check if a database change exists in the discovered changes.
*
Expand All @@ -304,19 +292,11 @@ public function hasChange(string $name): bool {
* @return bool True if the change has been applied, false otherwise.
*/
public function isApplied(string $name): bool {
try {
return $this->table('schema_changes')
->select(['change_name'])
->where('change_name', $name)
->execute()
->getRowsCount() == 1;
} catch (DatabaseException $ex) {
// If schema_changes table doesn't exist, no changes have been applied
if (strpos($ex->getMessage(), "doesn't exist") !== false) {
return false;
}
throw $ex;
}
return $this->table('schema_changes')
->select(['change_name'])
->where('change_name', $name)
->execute()
->getRowsCount() == 1;
}
/**
* Rollback database changes up to a specific change.
Expand Down Expand Up @@ -362,7 +342,8 @@ private function areDependenciesSatisfied(DatabaseChange $change): bool {
}
private function attemptRoolback(DatabaseChange $change, &$rolled) : bool {
try {
$change->rollback($this);
$migrationDb = $change->getDatabase() ?? $this;
$change->rollback($migrationDb);
$this->table('schema_changes')->delete()->where('change_name', $change->getName())->execute();
$rolled[] = $change;

Expand Down Expand Up @@ -399,36 +380,46 @@ private function findChangeByName(string $name): ?DatabaseChange {
return null;
}

private function scanPathForChanges() {
$changesPath = $this->getPath();

if (!is_dir($changesPath)) {
throw new DatabaseException('Invalid schema path: "'.$changesPath.'"');
}

$dirContents = array_diff(scandir($changesPath), ['.', '..']);

foreach ($dirContents as $file) {
if (is_file($changesPath.DIRECTORY_SEPARATOR.$file)) {
$clazz = $this->getNamespace().'\\'.explode('.', $file)[0];

try {
if (class_exists($clazz)) {
$instance = new $clazz();

if ($instance instanceof DatabaseChange) {
$this->dbChanges[] = $instance;
}
}
} catch (Exception|Error $ex) {
foreach ($this->onRegErrCallbacks as $callback) {
call_user_func_array($callback, [$ex]);
}
/**
* Register a database change.
*
* @param DatabaseChange|string $change The change instance or class name.
* @return bool True if registered successfully, false otherwise.
*/
public function register(DatabaseChange|string $change): bool {
try {
if (is_string($change)) {
if (!class_exists($change)) {
throw new Exception("Class does not exist: {$change}");
}

if (!is_subclass_of($change, DatabaseChange::class)) {
throw new Exception("Class is not a subclass of DatabaseChange: {$change}");
}

$change = new $change();
}

$this->dbChanges[] = $change;
return true;

} catch (Throwable $ex) {
foreach ($this->onRegErrCallbacks as $callback) {
call_user_func_array($callback, [$ex]);
}
return false;
}
}

$this->sortChangesByDependencies();
/**
* Register multiple database changes.
*
* @param array $changes Array of DatabaseChange instances or class names.
*/
public function registerAll(array $changes): void {
foreach ($changes as $change) {
$this->register($change);
}
}

private function shouldRunInEnvironment(DatabaseChange $change): bool {
Expand All @@ -449,7 +440,7 @@ private function sortChangesByDependencies() {
$this->dbChanges = $sorted;
}

private function topologicalSort(DatabaseChange $change, array &$visited, array &$sorted, array &$visiting = []) {
private function topologicalSort(DatabaseChange $change, array &$visited, array &$sorted, array &$visiting) {
$className = $change->getName();

if (isset($visiting[$className])) {
Expand Down
Loading
Loading