AC_services_website_design/system/Database/MigrationRunner.php

870 lines
22 KiB
PHP

<?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;
}
}