Select Git revision
Forked from
Stud.IP / Stud.IP
Source project has a limited visibility.
Code owners
Assign users and groups as approvers for specific file changes. Learn more.
Migrator.php 15.72 KiB
<?php
/**
* Migrator.php - versioning databases using migrations
*
* Migrations can manage the evolution of a schema used by several physical
* databases. It's a solution to the common problem of adding a field to make a
* new feature work in your local database, but being unsure of how to push that
* change to other developers and to the production server. With migrations, you
* can describe the transformations in self-contained classes that can be
* checked into version control systems and executed against another database
* that might be one, two, or five versions behind.
*
* General concept
*
* Migrations can be described as a triple {sequence of migrations,
* current schema version, target schema version}. The migrations are "deltas"
* which are employed to change the schema of your physical database. They even
* know how to reverse that change. These behaviours are mapped to the methods
* #up and #down of class Migration. A migration is a subclass of that class and
* you define the behaviours by overriding methods #up and #down.
* Broadly spoken the current schema version as well as the target schema
* version are "pointers" into the sequence of migrations. When migrating the
* sequence of migrations is traversed between current and target version.
* If the target version is greater than the current version, the #up methods
* of the migrations up to the target version's migration are called. If the
* target version is lower, the #down methods are used.
*
* Irreversible transformations
*
* Some transformations are destructive in a manner that cannot be reversed.
* Migrations of that kind should raise an Exception in their #down method.
*
* Example of use:
*
* Create a directory which will contain your migrations. In this directory
* create simple php files each containing a single subclass of class Migration.
* Name this file with the following convention in mind:
*
* (\d+)_([a-z_]+).php // (index)_(name).php
*
* 001_my_first_migration.php
* 002_another_migration.php
* 003_and_one_last.php
*
* Those numbers are used to order your migrations. The first migration has
* to be a 1 (but you can use leading 0). Every following migration has to be
* the successor to the previous migration. No gaps are allowed. Just use
* natural numbers starting with 1.
*
* When migrating those numbers are used to determine the migrations needed to
* fulfill the target version.
*
* The current schema version must somehow be persisted using a subclass of
* SchemaVersion.
*
* The name of the migration file is used to deduce the name of the subclass of
* class Migration contained in the file. Underscores divide the name into words
* and those words are then concatenated and camelcased.
*
* Examples:
*
* Name | Class
* ----------------------------------------------------------------------------
* my_first_migration | MyFirstMigration
* another_migration | AnotherMigration
* and_one_last | AndOneLast
*
* Those classes have to be subclasses of class Migration.
*
* Example:
*
* class MyFirstMigration extends Migration {
*
* function description() {
* # put your code here
* # return migration description
* }
*
* function up() {
* # put your code here
* # create a table for example
* }
*
* function down() {
* # put your code here
* # delete that table
* }
* }
*
* After writing your migrations you can invoke the migrator as follows:
*
* $path = '/path/to/my/migrations';
*
* $verbose = TRUE;
*
* # instantiate a schema version persistor
* # this one is file based and will use a file in /tmp
* $version = new FileSchemaVersion('/tmp');
*
* $migrator = new Migrator($path, $version, $verbose);
*
* # now migrate to target version
* $migrator->migrateTo(5);
*
* If you want to migrate to the highest migration, you can just use NULL as
* parameter:
*
* $migrator->migrateTo(null);
*
* @author Marcus Lunzenauer <mlunzena@uos.de>
* @copyright 2007 Marcus Lunzenauer <mlunzena@uos.de>
* @license GPL2 or any later version
* @package migrations
*/
class Migrator
{
/**
* Direction of migration, either "up" or "down" for each branch
*
* @var array
*/
private $direction;
/**
* Path to the migration files.
*
* @var string
*/
private $migrations_path;
/**
* Specifies the target version, may be NULL (alias for "highest migration")
*
* @var array
*/
private $target_versions;
/**
* How verbose shall the migrator be?
*
* @var boolean
*/
private $verbose;
/**
* The current schema version persistor.
*
* @var SchemaVersion
*/
private $schema_version;
/**
* Constructor.
*
* @param string a file path to the directory containing the migration
* files
* @param SchemaVersion the current schema version persistor
* @param boolean verbose or not
*
* @return void
*/
public function __construct($migrations_path, SchemaVersion $version, $verbose = false)
{
$this->migrations_path = $migrations_path;
$this->schema_version = $version;
$this->verbose = $verbose;
}
/**
* Sanity check to prevent doublettes.
*
* @param array an array of migration classes
* @param int the index of a migration
*/
private function assertUniqueMigrationVersion($migrations, $version)
{
if (isset($migrations[$version])) {
trigger_error(
"Multiple migrations have the version number {$version}",
E_USER_ERROR
);
}
}
/**
* Invoking this method will perform the migrations with an index between
* the current schema version (provided by the SchemaVersion object) and a
* target version calling the methods #up and #down in sequence.
*
* @param mixed the target version as an integer, array or NULL thus
* migrating to the top migrations
*/
public function migrateTo($target_version)
{
$migrations = $this->relevantMigrations($target_version);
$target_branch = $this->schema_version->getBranch();
# you're on the right version
if (empty($migrations)) {
$this->log('You are already at %d.', $this->schema_version->get($target_branch));
return;
}
$this->log(
'Currently at version %d. Now migrating %s to %d.',
$this->schema_version->get($target_branch),
$this->direction[$target_branch],
max($this->target_versions)
);
foreach ($migrations as $number => $migration) {
list($branch, $version) = $this->migrationBranchAndVersion($number);
$action = $this->isUp($branch) ? 'Migrating' : 'Reverting';
$migration->announce("{$action} %s", $number);
if ($migration->description()) {
$this->log($migration->description());
$this->log(str_repeat('-', 79));
}
$time_start = microtime(true);
$migration->migrate($this->direction[$branch]);
$action = $this->isUp($branch) ? 'Migrated' : 'Reverted';
$this->log('');
$migration->announce("{$action} in %ss", round(microtime(true) - $time_start, 3));
$this->log('');
$this->schema_version->set($this->isDown($branch) ? $version - 1 : $version, $branch);
$action = $this->isUp($branch) ? 'MIGRATE_UP' : 'MIGRATE_DOWN';
StudipLog::log($action, $number, $this->schema_version->getDomain());
}
}
/**
* Calculate the selected target versions for all relevant branches. If a
* single branch is selected for migration, only that branch and all its
* children are considered relevant.
*
* @param mixed the target version as an integer, array or NULL thus
* migrating to the top migrations
*
* @return array an associative array, whose keys are the branch names
* and whose values are the target versions
*/
public function targetVersions($target_version)
{
$top_versions = $this->topVersion(true);
$target_branch = $this->schema_version->getBranch();
if (is_array($target_version)) {
return $target_version;
}
$max_version = $target_branch ? $target_branch . '.' . $target_version : $target_version;
foreach ($top_versions as $branch => $version) {
if ($branch == $target_branch) {
if (isset($target_version)) {
$top_versions[$branch] = $target_version;
}
} else if ($target_branch && strpos($branch, $target_branch . '.') !== 0) {
unset($top_versions[$branch]);
} else if (isset($target_version) && version_compare($branch, $max_version) >= 0) {
$top_versions[$branch] = 0;
}
}
return $top_versions;
}
/**
* Invoking this method will return a list of migrations with an index between
* the current schema version (provided by the SchemaVersion object) and a
* target version calling the methods #up and #down in sequence.
*
* @param mixed the target version as an integer, array or NULL thus
* migrating to the top migrations
*
* @return array an associative array, whose keys are the migration's
* version and whose values are the migration objects
*/
public function relevantMigrations($target_version)
{
// Load migrations
$migrations = $this->migrationClasses();
// Determine correct target versions
$this->target_versions = $this->targetVersions($target_version);
// Determine migration direction
foreach ($this->target_versions as $branch => $version) {
if ($this->schema_version->get($branch) <= $version) {
$this->direction[$branch] = 'up';
} else {
$this->direction[$branch] = 'down';
}
}
// Sort migrations in correct order
uksort($migrations, 'version_compare');
if (!$this->isUp($branch)) {
$migrations = array_reverse($migrations, true);
}
$result = [];
foreach ($migrations as $version => $migration_file_and_class) {
if (!$this->relevantMigration($version)) {
continue;
}
list($file, $class) = $migration_file_and_class;
$migration = require_once $file;
if (!$migration instanceof Migration) {
$migration = new $class($this->verbose);
} else {
$migration->setVerbose($this->verbose);
}
$result[$version] = $migration;
}
return $result;
}
/**
* Checks wheter a migration has to be invoked, that is if the migration's
* version is included in the interval between current and target schema
* version.
*
* @param int the migration's version to check for inclusion
* @return bool TRUE if included, FALSE otherwise
*/
private function relevantMigration($version)
{
list($branch, $version) = $this->migrationBranchAndVersion($version);
$current_version = $this->schema_version->get($branch);
if (!isset($this->target_versions[$branch])) {
return false;
} else if ($this->isUp($branch)) {
return $current_version < $version
&& $version <= $this->target_versions[$branch];
} else if ($this->isDown($branch)) {
return $current_version >= $version
&& $version > $this->target_versions[$branch];
}
return false;
}
/**
* Am I migrating up?
*
* @param string schema branch
* @return bool TRUE if migrating up, FALSE otherwise
*/
private function isUp($branch)
{
return $this->direction[$branch] === 'up';
}
/**
* Am I migrating down?
*
* @param string schema branch
* @return bool TRUE if migrating down, FALSE otherwise
*/
private function isDown($branch)
{
return $this->direction[$branch] === 'down';
}
/**
* Maps a file name to a class name.
*
* @param string part of the file name
* @return string the derived class name
*/
protected function migrationClass($migration)
{
return str_replace(' ', '', ucwords(str_replace('_', ' ', $migration)));
}
/**
* Returns the collection (an array) of all migrations in this migrator's
* path.
*
* @return array an associative array, whose keys are the migration's
* version and whose values are arrays containing the
* migration's file and class name.
*/
public function migrationClasses()
{
$migrations = [];
foreach ($this->migrationFiles() as $file) {
list($version, $name) = $this->migrationVersionAndName($file);
$this->assertUniqueMigrationVersion($migrations, $version);
$migrations[$version] = [$file, $this->migrationClass($name)];
}
return $migrations;
}
/**
* Return all migration file names from my migrations_path.
*
* @return array a collection of file names
*/
protected function migrationFiles()
{
$files = glob($this->migrations_path . '/[0-9]*_*.php');
return $files;
}
/**
* Split a migration file name into that migration's version and name.
*
* @param string a file name
* @return array an array of two elements containing the migration's version
* and name.
*/
protected function migrationVersionAndName($migration_file)
{
$matches = [];
preg_match('/\b([0-9.]+)_([_a-z0-9]*)\.php$/', $migration_file, $matches);
return [$matches[1], $matches[2]];
}
/**
* Split a migration version into its branch and version parts.
*
* @param string a migration version
* @return array an array of two elements containing the migration's branch
* and version on this branch.
*/
public function migrationBranchAndVersion($version)
{
if (preg_match('/^(.*)\.([0-9]+)$/', $version, $matches)) {
$branch = preg_replace('/\b0+\B/', '', $matches[1]);
$version = (int) $matches[2];
} else {
$branch = '0';
$version = (int) $version;
}
return [$branch, $version];
}
/**
* Returns the top migration's version.
*
* @param bool return top version for all branches, not just default one
* @return int the top migration's version.
*/
public function topVersion($all_branches = false)
{
$versions = [0];
foreach (array_keys($this->migrationClasses()) as $version) {
list($branch, $version) = $this->migrationBranchAndVersion($version);
$versions[$branch] = max($versions[$branch], $version);
}
return $all_branches ? $versions : $versions[$this->schema_version->getBranch()];
}
/**
* Overridable method used to return a textual representation of what's going
* on in me. You can use me as you would use printf.
*
* @param string $format just a dummy value, instead use this method as you
* would use printf & co.
*/
protected function log($format)
{
if (!$this->verbose) {
return;
}
$args = func_get_args();
vprintf(array_shift($args) . "\n", $args);
}
}