<?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]?? null, $matches[2]?? null]; } /** * 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] ?? 0, $version); } return $all_branches ? $versions : $versions[$this->schema_version->getBranch()]; } /** * Returns the number of available, but not yet applied migrations. * * @return int migration count */ public function pendingMigrations() { $pending = 0; foreach (array_keys($this->migrationClasses()) as $version) { list($branch, $version) = $this->migrationBranchAndVersion($version); if ($this->schema_version->get($branch) < $version) { ++$pending; } } return $pending; } /** * 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); } }