You can not select more than 25 topics
Topics must start with a letter or number, can include dashes ('-') and can be up to 35 characters long.
869 lines
22 KiB
869 lines
22 KiB
<?php |
|
|
|
/** |
|
* This file is part of CodeIgniter 4 framework. |
|
* |
|
* (c) CodeIgniter Foundation <admin@codeigniter.com> |
|
* |
|
* For the full copyright and license information, please view |
|
* the LICENSE file that was distributed with this source code. |
|
*/ |
|
|
|
namespace CodeIgniter\Database; |
|
|
|
use CodeIgniter\CLI\CLI; |
|
use CodeIgniter\Events\Events; |
|
use CodeIgniter\Exceptions\ConfigException; |
|
use CodeIgniter\I18n\Time; |
|
use Config\Database; |
|
use Config\Migrations as MigrationsConfig; |
|
use Config\Services; |
|
use RuntimeException; |
|
use stdClass; |
|
|
|
/** |
|
* Class MigrationRunner |
|
*/ |
|
class MigrationRunner |
|
{ |
|
/** |
|
* Whether or not migrations are allowed to run. |
|
* |
|
* @var bool |
|
*/ |
|
protected $enabled = false; |
|
|
|
/** |
|
* Name of table to store meta information |
|
* |
|
* @var string |
|
*/ |
|
protected $table; |
|
|
|
/** |
|
* The Namespace where migrations can be found. |
|
* `null` is all namespaces. |
|
* |
|
* @var string|null |
|
*/ |
|
protected $namespace; |
|
|
|
/** |
|
* The database Group to migrate. |
|
* |
|
* @var string |
|
*/ |
|
protected $group; |
|
|
|
/** |
|
* The migration name. |
|
* |
|
* @var string |
|
*/ |
|
protected $name; |
|
|
|
/** |
|
* The pattern used to locate migration file versions. |
|
* |
|
* @var string |
|
*/ |
|
protected $regex = '/\A(\d{4}[_-]?\d{2}[_-]?\d{2}[_-]?\d{6})_(\w+)\z/'; |
|
|
|
/** |
|
* The main database connection. Used to store |
|
* migration information in. |
|
* |
|
* @var BaseConnection |
|
*/ |
|
protected $db; |
|
|
|
/** |
|
* If true, will continue instead of throwing |
|
* exceptions. |
|
* |
|
* @var bool |
|
*/ |
|
protected $silent = false; |
|
|
|
/** |
|
* used to return messages for CLI. |
|
* |
|
* @var array |
|
*/ |
|
protected $cliMessages = []; |
|
|
|
/** |
|
* Tracks whether we have already ensured |
|
* the table exists or not. |
|
* |
|
* @var bool |
|
*/ |
|
protected $tableChecked = false; |
|
|
|
/** |
|
* The full path to locate migration files. |
|
* |
|
* @var string |
|
*/ |
|
protected $path; |
|
|
|
/** |
|
* The database Group filter. |
|
* |
|
* @var string|null |
|
*/ |
|
protected $groupFilter; |
|
|
|
/** |
|
* Used to skip current migration. |
|
* |
|
* @var bool |
|
*/ |
|
protected $groupSkip = false; |
|
|
|
/** |
|
* The migration can manage multiple databases. So it should always use the |
|
* default DB group so that it creates the `migrations` table in the default |
|
* DB group. Therefore, passing $db is for testing purposes only. |
|
* |
|
* @param array|ConnectionInterface|string|null $db DB group. For testing purposes only. |
|
* |
|
* @throws ConfigException |
|
*/ |
|
public function __construct(MigrationsConfig $config, $db = null) |
|
{ |
|
$this->enabled = $config->enabled ?? false; |
|
$this->table = $config->table ?? 'migrations'; |
|
|
|
$this->namespace = APP_NAMESPACE; |
|
|
|
// Even if a DB connection is passed, since it is a test, |
|
// it is assumed to use the default group name |
|
$this->group = is_string($db) ? $db : config(Database::class)->defaultGroup; |
|
|
|
$this->db = db_connect($db); |
|
} |
|
|
|
/** |
|
* Locate and run all new migrations |
|
* |
|
* @return bool |
|
* |
|
* @throws ConfigException |
|
* @throws RuntimeException |
|
*/ |
|
public function latest(?string $group = null) |
|
{ |
|
if (! $this->enabled) { |
|
throw ConfigException::forDisabledMigrations(); |
|
} |
|
|
|
$this->ensureTable(); |
|
|
|
if ($group !== null) { |
|
$this->groupFilter = $group; |
|
$this->setGroup($group); |
|
} |
|
|
|
$migrations = $this->findMigrations(); |
|
|
|
if ($migrations === []) { |
|
return true; |
|
} |
|
|
|
foreach ($this->getHistory((string) $group) as $history) { |
|
unset($migrations[$this->getObjectUid($history)]); |
|
} |
|
|
|
$batch = $this->getLastBatch() + 1; |
|
|
|
foreach ($migrations as $migration) { |
|
if ($this->migrate('up', $migration)) { |
|
if ($this->groupSkip === true) { |
|
$this->groupSkip = false; |
|
|
|
continue; |
|
} |
|
|
|
$this->addHistory($migration, $batch); |
|
} else { |
|
$this->regress(-1); |
|
|
|
$message = lang('Migrations.generalFault'); |
|
|
|
if ($this->silent) { |
|
$this->cliMessages[] = "\t" . CLI::color($message, 'red'); |
|
|
|
return false; |
|
} |
|
|
|
throw new RuntimeException($message); |
|
} |
|
} |
|
|
|
$data = get_object_vars($this); |
|
$data['method'] = 'latest'; |
|
Events::trigger('migrate', $data); |
|
|
|
return true; |
|
} |
|
|
|
/** |
|
* Migrate down to a previous batch |
|
* |
|
* Calls each migration step required to get to the provided batch |
|
* |
|
* @param int $targetBatch Target batch number, or negative for a relative batch, 0 for all |
|
* @param string|null $group Deprecated. The designation has no effect. |
|
* |
|
* @return bool True on success, FALSE on failure or no migrations are found |
|
* |
|
* @throws ConfigException |
|
* @throws RuntimeException |
|
*/ |
|
public function regress(int $targetBatch = 0, ?string $group = null) |
|
{ |
|
if (! $this->enabled) { |
|
throw ConfigException::forDisabledMigrations(); |
|
} |
|
|
|
$this->ensureTable(); |
|
|
|
$batches = $this->getBatches(); |
|
|
|
if ($targetBatch < 0) { |
|
$targetBatch = $batches[count($batches) - 1 + $targetBatch] ?? 0; |
|
} |
|
|
|
if ($batches === [] && $targetBatch === 0) { |
|
return true; |
|
} |
|
|
|
if ($targetBatch !== 0 && ! in_array($targetBatch, $batches, true)) { |
|
$message = lang('Migrations.batchNotFound') . $targetBatch; |
|
|
|
if ($this->silent) { |
|
$this->cliMessages[] = "\t" . CLI::color($message, 'red'); |
|
|
|
return false; |
|
} |
|
|
|
throw new RuntimeException($message); |
|
} |
|
|
|
$tmpNamespace = $this->namespace; |
|
|
|
$this->namespace = null; |
|
$allMigrations = $this->findMigrations(); |
|
|
|
$migrations = []; |
|
|
|
while ($batch = array_pop($batches)) { |
|
if ($batch <= $targetBatch) { |
|
break; |
|
} |
|
|
|
foreach ($this->getBatchHistory($batch, 'desc') as $history) { |
|
$uid = $this->getObjectUid($history); |
|
|
|
if (! isset($allMigrations[$uid])) { |
|
$message = lang('Migrations.gap') . ' ' . $history->version; |
|
|
|
if ($this->silent) { |
|
$this->cliMessages[] = "\t" . CLI::color($message, 'red'); |
|
|
|
return false; |
|
} |
|
|
|
throw new RuntimeException($message); |
|
} |
|
|
|
$migration = $allMigrations[$uid]; |
|
$migration->history = $history; |
|
$migrations[] = $migration; |
|
} |
|
} |
|
|
|
foreach ($migrations as $migration) { |
|
if ($this->migrate('down', $migration)) { |
|
$this->removeHistory($migration->history); |
|
} else { |
|
$message = lang('Migrations.generalFault'); |
|
|
|
if ($this->silent) { |
|
$this->cliMessages[] = "\t" . CLI::color($message, 'red'); |
|
|
|
return false; |
|
} |
|
|
|
throw new RuntimeException($message); |
|
} |
|
} |
|
|
|
$data = get_object_vars($this); |
|
$data['method'] = 'regress'; |
|
Events::trigger('migrate', $data); |
|
|
|
$this->namespace = $tmpNamespace; |
|
|
|
return true; |
|
} |
|
|
|
/** |
|
* Migrate a single file regardless of order or batches. |
|
* Method "up" or "down" determined by presence in history. |
|
* NOTE: This is not recommended and provided mostly for testing. |
|
* |
|
* @param string $path Full path to a valid migration file |
|
* @param string $path Namespace of the target migration |
|
*/ |
|
public function force(string $path, string $namespace, ?string $group = null) |
|
{ |
|
if (! $this->enabled) { |
|
throw ConfigException::forDisabledMigrations(); |
|
} |
|
|
|
$this->ensureTable(); |
|
|
|
if ($group !== null) { |
|
$this->groupFilter = $group; |
|
$this->setGroup($group); |
|
} |
|
|
|
$migration = $this->migrationFromFile($path, $namespace); |
|
if (empty($migration)) { |
|
$message = lang('Migrations.notFound'); |
|
|
|
if ($this->silent) { |
|
$this->cliMessages[] = "\t" . CLI::color($message, 'red'); |
|
|
|
return false; |
|
} |
|
|
|
throw new RuntimeException($message); |
|
} |
|
|
|
$method = 'up'; |
|
$this->setNamespace($migration->namespace); |
|
|
|
foreach ($this->getHistory($this->group) as $history) { |
|
if ($this->getObjectUid($history) === $migration->uid) { |
|
$method = 'down'; |
|
$migration->history = $history; |
|
break; |
|
} |
|
} |
|
|
|
if ($method === 'up') { |
|
$batch = $this->getLastBatch() + 1; |
|
|
|
if ($this->migrate('up', $migration) && $this->groupSkip === false) { |
|
$this->addHistory($migration, $batch); |
|
|
|
return true; |
|
} |
|
|
|
$this->groupSkip = false; |
|
} elseif ($this->migrate('down', $migration)) { |
|
$this->removeHistory($migration->history); |
|
|
|
return true; |
|
} |
|
|
|
$message = lang('Migrations.generalFault'); |
|
|
|
if ($this->silent) { |
|
$this->cliMessages[] = "\t" . CLI::color($message, 'red'); |
|
|
|
return false; |
|
} |
|
|
|
throw new RuntimeException($message); |
|
} |
|
|
|
/** |
|
* Retrieves list of available migration scripts |
|
* |
|
* @return array List of all located migrations by their UID |
|
*/ |
|
public function findMigrations(): array |
|
{ |
|
$namespaces = $this->namespace ? [$this->namespace] : array_keys(Services::autoloader()->getNamespace()); |
|
$migrations = []; |
|
|
|
foreach ($namespaces as $namespace) { |
|
if (ENVIRONMENT !== 'testing' && $namespace === 'Tests\Support') { |
|
continue; |
|
} |
|
|
|
foreach ($this->findNamespaceMigrations($namespace) as $migration) { |
|
$migrations[$migration->uid] = $migration; |
|
} |
|
} |
|
|
|
// Sort migrations ascending by their UID (version) |
|
ksort($migrations); |
|
|
|
return $migrations; |
|
} |
|
|
|
/** |
|
* Retrieves a list of available migration scripts for one namespace |
|
*/ |
|
public function findNamespaceMigrations(string $namespace): array |
|
{ |
|
$migrations = []; |
|
$locator = Services::locator(true); |
|
|
|
if (! empty($this->path)) { |
|
helper('filesystem'); |
|
$dir = rtrim($this->path, DIRECTORY_SEPARATOR) . '/'; |
|
$files = get_filenames($dir, true, false, false); |
|
} else { |
|
$files = $locator->listNamespaceFiles($namespace, '/Database/Migrations/'); |
|
} |
|
|
|
foreach ($files as $file) { |
|
$file = empty($this->path) ? $file : $this->path . str_replace($this->path, '', $file); |
|
|
|
if ($migration = $this->migrationFromFile($file, $namespace)) { |
|
$migrations[] = $migration; |
|
} |
|
} |
|
|
|
return $migrations; |
|
} |
|
|
|
/** |
|
* Create a migration object from a file path. |
|
* |
|
* @param string $path Full path to a valid migration file. |
|
* |
|
* @return false|object Returns the migration object, or false on failure |
|
*/ |
|
protected function migrationFromFile(string $path, string $namespace) |
|
{ |
|
if (substr($path, -4) !== '.php') { |
|
return false; |
|
} |
|
|
|
$filename = basename($path, '.php'); |
|
|
|
if (! preg_match($this->regex, $filename)) { |
|
return false; |
|
} |
|
|
|
$locator = Services::locator(true); |
|
|
|
$migration = new stdClass(); |
|
|
|
$migration->version = $this->getMigrationNumber($filename); |
|
$migration->name = $this->getMigrationName($filename); |
|
$migration->path = $path; |
|
$migration->class = $locator->getClassname($path); |
|
$migration->namespace = $namespace; |
|
$migration->uid = $this->getObjectUid($migration); |
|
|
|
return $migration; |
|
} |
|
|
|
/** |
|
* Allows other scripts to modify on the fly as needed. |
|
* |
|
* @return MigrationRunner |
|
*/ |
|
public function setNamespace(?string $namespace) |
|
{ |
|
$this->namespace = $namespace; |
|
|
|
return $this; |
|
} |
|
|
|
/** |
|
* Allows other scripts to modify on the fly as needed. |
|
* |
|
* @return MigrationRunner |
|
*/ |
|
public function setGroup(string $group) |
|
{ |
|
$this->group = $group; |
|
|
|
return $this; |
|
} |
|
|
|
/** |
|
* @return MigrationRunner |
|
*/ |
|
public function setName(string $name) |
|
{ |
|
$this->name = $name; |
|
|
|
return $this; |
|
} |
|
|
|
/** |
|
* If $silent == true, then will not throw exceptions and will |
|
* attempt to continue gracefully. |
|
* |
|
* @return MigrationRunner |
|
*/ |
|
public function setSilent(bool $silent) |
|
{ |
|
$this->silent = $silent; |
|
|
|
return $this; |
|
} |
|
|
|
/** |
|
* Extracts the migration number from a filename |
|
* |
|
* @param string $migration A migration filename w/o path. |
|
*/ |
|
protected function getMigrationNumber(string $migration): string |
|
{ |
|
preg_match($this->regex, $migration, $matches); |
|
|
|
return count($matches) ? $matches[1] : '0'; |
|
} |
|
|
|
/** |
|
* Extracts the migration name from a filename |
|
* |
|
* Note: The migration name should be the classname, but maybe they are |
|
* different. |
|
* |
|
* @param string $migration A migration filename w/o path. |
|
*/ |
|
protected function getMigrationName(string $migration): string |
|
{ |
|
preg_match($this->regex, $migration, $matches); |
|
|
|
return count($matches) ? $matches[2] : ''; |
|
} |
|
|
|
/** |
|
* Uses the non-repeatable portions of a migration or history |
|
* to create a sortable unique key |
|
* |
|
* @param object $object migration or $history |
|
*/ |
|
public function getObjectUid($object): string |
|
{ |
|
return preg_replace('/[^0-9]/', '', $object->version) . $object->class; |
|
} |
|
|
|
/** |
|
* Retrieves messages formatted for CLI output |
|
*/ |
|
public function getCliMessages(): array |
|
{ |
|
return $this->cliMessages; |
|
} |
|
|
|
/** |
|
* Clears any CLI messages. |
|
* |
|
* @return MigrationRunner |
|
*/ |
|
public function clearCliMessages() |
|
{ |
|
$this->cliMessages = []; |
|
|
|
return $this; |
|
} |
|
|
|
/** |
|
* Truncates the history table. |
|
*/ |
|
public function clearHistory() |
|
{ |
|
if ($this->db->tableExists($this->table)) { |
|
$this->db->table($this->table)->truncate(); |
|
} |
|
} |
|
|
|
/** |
|
* Add a history to the table. |
|
* |
|
* @param object $migration |
|
*/ |
|
protected function addHistory($migration, int $batch) |
|
{ |
|
$this->db->table($this->table)->insert([ |
|
'version' => $migration->version, |
|
'class' => $migration->class, |
|
'group' => $this->group, |
|
'namespace' => $migration->namespace, |
|
'time' => Time::now()->getTimestamp(), |
|
'batch' => $batch, |
|
]); |
|
|
|
if (is_cli()) { |
|
$this->cliMessages[] = sprintf( |
|
"\t%s(%s) %s_%s", |
|
CLI::color(lang('Migrations.added'), 'yellow'), |
|
$migration->namespace, |
|
$migration->version, |
|
$migration->class |
|
); |
|
} |
|
} |
|
|
|
/** |
|
* Removes a single history |
|
* |
|
* @param object $history |
|
*/ |
|
protected function removeHistory($history) |
|
{ |
|
$this->db->table($this->table)->where('id', $history->id)->delete(); |
|
|
|
if (is_cli()) { |
|
$this->cliMessages[] = sprintf( |
|
"\t%s(%s) %s_%s", |
|
CLI::color(lang('Migrations.removed'), 'yellow'), |
|
$history->namespace, |
|
$history->version, |
|
$history->class |
|
); |
|
} |
|
} |
|
|
|
/** |
|
* Grabs the full migration history from the database for a group |
|
*/ |
|
public function getHistory(string $group = 'default'): array |
|
{ |
|
$this->ensureTable(); |
|
|
|
$builder = $this->db->table($this->table); |
|
|
|
// If group was specified then use it |
|
if ($group !== '') { |
|
$builder->where('group', $group); |
|
} |
|
|
|
// If a namespace was specified then use it |
|
if ($this->namespace) { |
|
$builder->where('namespace', $this->namespace); |
|
} |
|
|
|
$query = $builder->orderBy('id', 'ASC')->get(); |
|
|
|
return ! empty($query) ? $query->getResultObject() : []; |
|
} |
|
|
|
/** |
|
* Returns the migration history for a single batch. |
|
* |
|
* @param string $order |
|
*/ |
|
public function getBatchHistory(int $batch, $order = 'asc'): array |
|
{ |
|
$this->ensureTable(); |
|
|
|
$query = $this->db->table($this->table) |
|
->where('batch', $batch) |
|
->orderBy('id', $order) |
|
->get(); |
|
|
|
return ! empty($query) ? $query->getResultObject() : []; |
|
} |
|
|
|
/** |
|
* Returns all the batches from the database history in order |
|
*/ |
|
public function getBatches(): array |
|
{ |
|
$this->ensureTable(); |
|
|
|
$batches = $this->db->table($this->table) |
|
->select('batch') |
|
->distinct() |
|
->orderBy('batch', 'asc') |
|
->get() |
|
->getResultArray(); |
|
|
|
return array_map('intval', array_column($batches, 'batch')); |
|
} |
|
|
|
/** |
|
* Returns the value of the last batch in the database. |
|
*/ |
|
public function getLastBatch(): int |
|
{ |
|
$this->ensureTable(); |
|
|
|
$batch = $this->db->table($this->table) |
|
->selectMax('batch') |
|
->get() |
|
->getResultObject(); |
|
|
|
$batch = is_array($batch) && count($batch) |
|
? end($batch)->batch |
|
: 0; |
|
|
|
return (int) $batch; |
|
} |
|
|
|
/** |
|
* Returns the version number of the first migration for a batch. |
|
* Mostly just for tests. |
|
*/ |
|
public function getBatchStart(int $batch): string |
|
{ |
|
if ($batch < 0) { |
|
$batches = $this->getBatches(); |
|
$batch = $batches[count($batches) - 1] ?? 0; |
|
} |
|
|
|
$migration = $this->db->table($this->table) |
|
->where('batch', $batch) |
|
->orderBy('id', 'asc') |
|
->limit(1) |
|
->get() |
|
->getResultObject(); |
|
|
|
return count($migration) ? $migration[0]->version : '0'; |
|
} |
|
|
|
/** |
|
* Returns the version number of the last migration for a batch. |
|
* Mostly just for tests. |
|
*/ |
|
public function getBatchEnd(int $batch): string |
|
{ |
|
if ($batch < 0) { |
|
$batches = $this->getBatches(); |
|
$batch = $batches[count($batches) - 1] ?? 0; |
|
} |
|
|
|
$migration = $this->db->table($this->table) |
|
->where('batch', $batch) |
|
->orderBy('id', 'desc') |
|
->limit(1) |
|
->get() |
|
->getResultObject(); |
|
|
|
return count($migration) ? $migration[0]->version : 0; |
|
} |
|
|
|
/** |
|
* Ensures that we have created our migrations table |
|
* in the database. |
|
*/ |
|
public function ensureTable() |
|
{ |
|
if ($this->tableChecked || $this->db->tableExists($this->table)) { |
|
return; |
|
} |
|
|
|
$forge = Database::forge($this->db); |
|
|
|
$forge->addField([ |
|
'id' => [ |
|
'type' => 'BIGINT', |
|
'constraint' => 20, |
|
'unsigned' => true, |
|
'auto_increment' => true, |
|
], |
|
'version' => [ |
|
'type' => 'VARCHAR', |
|
'constraint' => 255, |
|
'null' => false, |
|
], |
|
'class' => [ |
|
'type' => 'VARCHAR', |
|
'constraint' => 255, |
|
'null' => false, |
|
], |
|
'group' => [ |
|
'type' => 'VARCHAR', |
|
'constraint' => 255, |
|
'null' => false, |
|
], |
|
'namespace' => [ |
|
'type' => 'VARCHAR', |
|
'constraint' => 255, |
|
'null' => false, |
|
], |
|
'time' => [ |
|
'type' => 'INT', |
|
'constraint' => 11, |
|
'null' => false, |
|
], |
|
'batch' => [ |
|
'type' => 'INT', |
|
'constraint' => 11, |
|
'unsigned' => true, |
|
'null' => false, |
|
], |
|
]); |
|
|
|
$forge->addPrimaryKey('id'); |
|
$forge->createTable($this->table, true); |
|
|
|
$this->tableChecked = true; |
|
} |
|
|
|
/** |
|
* Handles the actual running of a migration. |
|
* |
|
* @param string $direction "up" or "down" |
|
* @param object $migration The migration to run |
|
*/ |
|
protected function migrate($direction, $migration): bool |
|
{ |
|
include_once $migration->path; |
|
|
|
$class = $migration->class; |
|
$this->setName($migration->name); |
|
|
|
// Validate the migration file structure |
|
if (! class_exists($class, false)) { |
|
$message = sprintf(lang('Migrations.classNotFound'), $class); |
|
|
|
if ($this->silent) { |
|
$this->cliMessages[] = "\t" . CLI::color($message, 'red'); |
|
|
|
return false; |
|
} |
|
|
|
throw new RuntimeException($message); |
|
} |
|
|
|
/** @var Migration $instance */ |
|
$instance = new $class(Database::forge($this->db)); |
|
$group = $instance->getDBGroup() ?? $this->group; |
|
|
|
if (ENVIRONMENT !== 'testing' && $group === 'tests' && $this->groupFilter !== 'tests') { |
|
// @codeCoverageIgnoreStart |
|
$this->groupSkip = true; |
|
|
|
return true; |
|
// @codeCoverageIgnoreEnd |
|
} |
|
|
|
if ($direction === 'up' && $this->groupFilter !== null && $this->groupFilter !== $group) { |
|
$this->groupSkip = true; |
|
|
|
return true; |
|
} |
|
|
|
if (! is_callable([$instance, $direction])) { |
|
$message = sprintf(lang('Migrations.missingMethod'), $direction); |
|
|
|
if ($this->silent) { |
|
$this->cliMessages[] = "\t" . CLI::color($message, 'red'); |
|
|
|
return false; |
|
} |
|
|
|
throw new RuntimeException($message); |
|
} |
|
|
|
$instance->{$direction}(); |
|
|
|
return true; |
|
} |
|
}
|
|
|