Newer
Older
framework / system / Database / MigrationRunner.php
@Jim Parry Jim Parry on 30 Jan 2019 18 KB Release 4.0.0-alpha.5
<?php namespace CodeIgniter\Database;

/**
 * CodeIgniter
 *
 * An open source application development framework for PHP
 *
 * This content is released under the MIT License (MIT)
 *
 * Copyright (c) 2014-2019 British Columbia Institute of Technology
 *
 * Permission is hereby granted, free of charge, to any person obtaining a copy
 * of this software and associated documentation files (the "Software"), to deal
 * in the Software without restriction, including without limitation the rights
 * to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
 * copies of the Software, and to permit persons to whom the Software is
 * furnished to do so, subject to the following conditions:
 *
 * The above copyright notice and this permission notice shall be included in
 * all copies or substantial portions of the Software.
 *
 * THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
 * IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
 * FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
 * AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
 * LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
 * OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN
 * THE SOFTWARE.
 *
 * @package    CodeIgniter
 * @author     CodeIgniter Dev Team
 * @copyright  2014-2019 British Columbia Institute of Technology (https://bcit.ca/)
 * @license    https://opensource.org/licenses/MIT	MIT License
 * @link       https://codeigniter.com
 * @since      Version 3.0.0
 * @filesource
 */

use Config\Autoload;
use CodeIgniter\CLI\CLI;
use CodeIgniter\Config\BaseConfig;
use CodeIgniter\Exceptions\ConfigException;

/**
 * Class MigrationRunner
 */
class MigrationRunner
{

	/**
	 * Whether or not migrations are allowed to run.
	 *
	 * @var boolean
	 */
	protected $enabled = false;

	/**
	 * The type of migrations to use (sequential or timestamp)
	 *
	 * @var string
	 */
	protected $type;

	/**
	 * Name of table to store meta information
	 *
	 * @var string
	 */
	protected $table;

	/**
	 * The version that current() will take us to.
	 *
	 * @var integer
	 */
	protected $currentVersion = 0;

	/**
	 * The Namespace  where migrations can be found.
	 *
	 * @var string
	 */
	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;

	/**
	 * The main database connection. Used to store
	 * migration information in.
	 *
	 * @var ConnectionInterface
	 */
	protected $db;

	/**
	 * If true, will continue instead of throwing
	 * exceptions.
	 *
	 * @var boolean
	 */
	protected $silent = false;

	/**
	 * used to return messages for CLI.
	 *
	 * @var boolean
	 */
	protected $cliMessages = [];

	/**
	 * Tracks whether we have already ensured
	 * the table exists or not.
	 *
	 * @var boolean
	 */
	protected $tableChecked = false;

	/**
	 * The full path to locate migration files.
	 *
	 * @var string
	 */
	protected $path;

	//--------------------------------------------------------------------

	/**
	 * Constructor.
	 *
	 * When passing in $db, you may pass any of the following to connect:
	 * - group name
	 * - existing connection instance
	 * - array of database configuration values
	 *
	 * @param BaseConfig                                             $config
	 * @param \CodeIgniter\Database\ConnectionInterface|array|string $db
	 *
	 * @throws ConfigException
	 */
	public function __construct(BaseConfig $config, $db = null)
	{
		$this->enabled        = $config->enabled ?? false;
		$this->type           = $config->type ?? 'timestamp';
		$this->table          = $config->table ?? 'migrations';
		$this->currentVersion = $config->currentVersion ?? 0;

		// Default name space is the app namespace
		$this->namespace = APP_NAMESPACE;

		// get default database group
		$config      = config('Database');
		$this->group = $config->defaultGroup;
		unset($config);

		if (! in_array($this->type, ['sequential', 'timestamp']))
		{
			throw ConfigException::forInvalidMigrationType($this->type);
		}

		// Migration basename regex
		$this->regex = ($this->type === 'timestamp') ? '/^\d{14}_(\w+)$/' : '/^\d{3}_(\w+)$/';

		// If no db connection passed in, use
		// default database group.
		$this->db = db_connect($db);
	}

	//--------------------------------------------------------------------

	/**
	 * Migrate to a schema version
	 *
	 * Calls each migration step required to get to the schema version of
	 * choice
	 *
	 * @param string      $targetVersion Target schema version
	 * @param string|null $namespace
	 * @param string|null $group
	 *
	 * @return mixed TRUE if no migrations are found, current version string on success, FALSE on failure
	 * @throws ConfigException
	 */
	public function version(string $targetVersion, string $namespace = null, string $group = null)
	{
		if (! $this->enabled)
		{
			throw ConfigException::forDisabledMigrations();
		}

		$this->ensureTable();

		// Set Namespace if not null
		if (! is_null($namespace))
		{
			$this->setNamespace($namespace);
		}

		// Set database group if not null
		if (! is_null($group))
		{
			$this->setGroup($group);
		}

		// Sequential versions need adjusting to 3 places so they can be found later.
		if ($this->type === 'sequential')
		{
			$targetVersion = str_pad($targetVersion, 3, '0', STR_PAD_LEFT);
		}

		$migrations = $this->findMigrations();

		if (empty($migrations))
		{
			return true;
		}

		// Get Namespace current version
		// Note: We use strings, so that timestamp versions work on 32-bit systems
		$currentVersion = $this->getVersion();
		if ($targetVersion > $currentVersion)
		{
			// Moving Up
			$method = 'up';
			ksort($migrations);
		}
		else
		{
			// Moving Down, apply in reverse order
			$method = 'down';
			krsort($migrations);
		}

		// Check Migration consistency
		$this->checkMigrations($migrations, $method, $targetVersion);

		// loop migration for each namespace (module)
		foreach ($migrations as $version => $migration)
		{
			// Only include migrations within the scoop
			if (($method === 'up' && $version > $currentVersion && $version <= $targetVersion) || ( $method === 'down' && $version <= $currentVersion && $version > $targetVersion)
			)
			{
				include_once $migration->path;
				// Get namespaced class name
				$class = $this->namespace . '\Database\Migrations\Migration_' . ($migration->name);

				$this->setName($migration->name);

				// Validate the migration file structure
				if (! class_exists($class, false))
				{
					throw new \RuntimeException(sprintf(lang('Migrations.classNotFound'), $class));
				}

				// Forcing migration to selected database group
				$instance = new $class(\Config\Database::forge($this->group));

				if (! is_callable([$instance, $method]))
				{
					throw new \RuntimeException(sprintf(lang('Migrations.missingMethod'), $method));
				}

				$instance->{$method}();
				if ($method === 'up')
				{
					$this->addHistory($migration->version);
				}
				elseif ($method === 'down')
				{
					$this->removeHistory($migration->version);
				}
			}
		}

		return true;
	}

	//--------------------------------------------------------------------

	/**
	 * Sets the schema to the latest migration
	 *
	 * @param string|null $namespace
	 * @param string|null $group
	 *
	 * @return mixed    Current version string on success, FALSE on failure
	 */
	public function latest(string $namespace = null, string $group = null)
	{
		$this->ensureTable();

		// Set Namespace if not null
		if (! is_null($namespace))
		{
			$this->setNamespace($namespace);
		}
		// Set database group if not null
		if (! is_null($group))
		{
			$this->setGroup($group);
		}

		$migrations = $this->findMigrations();

		$lastMigration = end($migrations)->version ?? 0;

		// Calculate the last migration step from existing migration
		// filenames and proceed to the standard version migration
		return $this->version($lastMigration);
	}

	//--------------------------------------------------------------------

	/**
	 * Sets the schema to the latest migration for all namespaces
	 *
	 * @param string|null $group
	 *
	 * @return boolean
	 */
	public function latestAll(string $group = null)
	{
		$this->ensureTable();

		// Set database group if not null
		if (! is_null($group))
		{
			$this->setGroup($group);
		}

		// Get all namespaces form  PSR4 paths.
		$config     = config('Autoload');
		$namespaces = $config->psr4;

		foreach ($namespaces as $namespace => $path)
		{
			$this->setNamespace($namespace);
			$migrations = $this->findMigrations();

			if (empty($migrations))
			{
				continue;
			}

			$lastMigration = end($migrations)->version;
			// No New migrations to add
			if ($lastMigration === $this->getVersion())
			{
				continue;
			}

			// Calculate the last migration step from existing migration
			// filenames and proceed to the standard version migration
			$this->version($lastMigration);
		}

		return true;
	}

	//--------------------------------------------------------------------

	/**
	 * Sets the (APP_NAMESPACE) schema to $currentVersion in migration config file
	 *
	 * @param string|null $group
	 *
	 * @return mixed    TRUE if no migrations are found, current version string on success, FALSE on failure
	 */
	public function current(string $group = null)
	{
		$this->ensureTable();

		// Set database group if not null
		if (! is_null($group))
		{
			$this->setGroup($group);
		}

		return $this->version($this->currentVersion);
	}

	//--------------------------------------------------------------------

	/**
	 * Retrieves list of available migration scripts
	 *
	 * @return array    list of migrations as $version for one namespace
	 */
	public function findMigrations()
	{
		$migrations = [];
		helper('filesystem');

		// If $this->path contains a valid directory use it.
		if (! empty($this->path))
		{
			$dir = rtrim($this->path, DIRECTORY_SEPARATOR) . '/';
		}
		// Otherwise, get namespace location form  PSR4 paths
		// and add Database/Migrations for a standard loation.
		else
		{
			$config = config('Autoload');

			$location = $config->psr4[$this->namespace];

			// Setting migration directories.
			$dir = rtrim($location, DIRECTORY_SEPARATOR) . '/Database/Migrations/';
		}

		// Load all *_*.php files in the migrations path
		// We can't use glob if we want it to be testable....
		$files = get_filenames($dir, true);

		foreach ($files as $file)
		{
			if (substr($file, -4) !== '.php')
			{
				continue;
			}

			// Remove the extension
			$name = basename($file, '.php');

			// Filter out non-migration files
			if (preg_match($this->regex, $name))
			{
				// Create migration object using stdClass
				$migration = new \stdClass();

				// Get migration version number
				$migration->version = $this->getMigrationNumber($name);
				$migration->name    = $this->getMigrationName($name);
				$migration->path    = ! empty($this->path) && strpos($file, $this->path) !== 0
					? $this->path . $file
					: $file;

				// Add to migrations[version]
				$migrations[$migration->version] = $migration;
			}
		}

		return $migrations;
	}

	//--------------------------------------------------------------------

	/**
	 *  checks if the list of available migration scripts list are consistent
	 *  if sequential check if no gaps and check if all consistent with migrations table if downgrading
	 *  if timestamp check if consistent with migrations table if downgrading
	 *
	 * @param array  $migrations
	 * @param string $method
	 * @param string $targetversion
	 *
	 * @return boolean
	 */
	protected function checkMigrations(array $migrations, string $method, string $targetversion)
	{
		// Check if no migrations found
		if (empty($migrations))
		{
			if ($this->silent)
			{
				return false;
			}
			throw new \RuntimeException(lang('Migrations.empty'));
		}

		// Check if $targetversion file is found
		if ((int)$targetversion !== 0 && ! array_key_exists($targetversion, $migrations))
		{
			if ($this->silent)
			{
				return false;
			}
			throw new \RuntimeException(lang('Migrations.notFound') . $targetversion);
		}

		ksort($migrations);

		if ($method === 'down')
		{
			$history_migrations = $this->getHistory($this->group);
			$history_size       = count($history_migrations) - 1;
		}
		// Check for sequence gaps
		$loop = 0;
		foreach ($migrations as $migration)
		{
			if ($this->type === 'sequential' && abs($migration->version - $loop) > 1)
			{
				throw new \RuntimeException(lang('Migrations.gap') . ' ' . $migration->version);
			}
			// Check if all old migration files are all available to do downgrading
			if ($method === 'down')
			{
				if ($loop <= $history_size && $history_migrations[$loop]['version'] !== $migration->version)
				{
					throw new \RuntimeException(lang('Migrations.gap') . ' ' . $migration->version);
				}
			}
			$loop ++;
		}

		return true;
	}

	//--------------------------------------------------------------------

	/**
	 * Sets the path to the base directory that will be used
	 * when locating migrations. If left null, the value will
	 * be chosen from $this->namespace's directory.
	 *
	 * @param string|null $path
	 *
	 * @return $this
	 */
	public function setPath(string $path = null)
	{
		$this->path = $path;

		return $this;
	}

	/**
	 * Set namespace.
	 * Allows other scripts to modify on the fly as needed.
	 *
	 * @param string $namespace
	 *
	 * @return MigrationRunner
	 */
	public function setNamespace(string $namespace)
	{
		$this->namespace = $namespace;

		return $this;
	}

	//--------------------------------------------------------------------

	/**
	 * Set database Group.
	 * Allows other scripts to modify on the fly as needed.
	 *
	 * @param string $group
	 *
	 * @return MigrationRunner
	 */
	public function setGroup(string $group)
	{
		$this->group = $group;

		return $this;
	}

	//--------------------------------------------------------------------

	/**
	 * Set migration Name.
	 *
	 * @param string $name
	 *
	 * @return \CodeIgniter\Database\MigrationRunner
	 */
	public function setName(string $name)
	{
		$this->name = $name;

		return $this;
	}

	//--------------------------------------------------------------------

	/**
	 * Grabs the full migration history from the database.
	 *
	 * @param string $group
	 *
	 * @return array
	 */
	public function getHistory(string $group = 'default')
	{
		$this->ensureTable();

		$query = $this->db->table($this->table)
				->where('group', $group)
				->where('namespace', $this->namespace)
				->orderBy('version', 'ASC')
				->get();

		if (! $query)
		{
			return [];
		}

		return $query->getResultArray();
	}

	//--------------------------------------------------------------------

	/**
	 * If $silent == true, then will not throw exceptions and will
	 * attempt to continue gracefully.
	 *
	 * @param boolean $silent
	 *
	 * @return MigrationRunner
	 */
	public function setSilent(bool $silent)
	{
		$this->silent = $silent;

		return $this;
	}

	//--------------------------------------------------------------------

	/**
	 * Extracts the migration number from a filename
	 *
	 * @param string $migration
	 *
	 * @return string    Numeric portion of a migration filename
	 */
	protected function getMigrationNumber(string $migration)
	{
		return sscanf($migration, '%[0-9]+', $number) ? $number : '0';
	}

	//--------------------------------------------------------------------

	/**
	 * Extracts the migration class name from a filename
	 *
	 * @param string $migration
	 *
	 * @return string    text portion of a migration filename
	 */
	protected function getMigrationName(string $migration)
	{
		$parts = explode('_', $migration);
		array_shift($parts);

		return implode('_', $parts);
	}

	//--------------------------------------------------------------------

	/**
	 * Retrieves current schema version
	 *
	 * @return string    Current migration version
	 */
	protected function getVersion()
	{
		$this->ensureTable();

		$row = $this->db->table($this->table)
				->select('version')
				->where('group', $this->group)
				->where('namespace', $this->namespace)
				->orderBy('version', 'DESC')
				->get();

		return $row && ! is_null($row->getRow()) ? $row->getRow()->version : '0';
	}

	//--------------------------------------------------------------------

	/**
	 * Retrieves current schema version
	 *
	 * @return string    Current migration version
	 */
	public function getCliMessages()
	{
		return $this->cliMessages;
	}

	//--------------------------------------------------------------------

	/**
	 * Stores the current schema version.
	 *
	 * @param string $version
	 *
	 * @internal param string $migration Migration reached
	 */
	protected function addHistory(string $version)
	{
		$this->db->table($this->table)
				->insert([
					'version'   => $version,
					'name'      => $this->name,
					'group'     => $this->group,
					'namespace' => $this->namespace,
					'time'      => time(),
				]);
		if (is_cli())
		{
			$this->cliMessages[] = "\t" . CLI::color(lang('Migrations.added'), 'yellow') . "($this->namespace) " . $version . '_' . $this->name;
		}
	}

	//--------------------------------------------------------------------

	/**
	 * Removes a single history
	 *
	 * @param string $version
	 */
	protected function removeHistory(string $version)
	{
		$this->db->table($this->table)
				->where('version', $version)
				->where('group', $this->group)
				->where('namespace', $this->namespace)
				->delete();
		if (is_cli())
		{
			$this->cliMessages[] = "\t" . CLI::color(lang('Migrations.removed'), 'yellow') . "($this->namespace) " . $version . '_' . $this->name;
		}
	}

	//--------------------------------------------------------------------

	/**
	 * Ensures that we have created our migrations table
	 * in the database.
	 */
	public function ensureTable()
	{
		if ($this->tableChecked || $this->db->tableExists($this->table))
		{
			return;
		}

		$forge = \Config\Database::forge($this->db);

		$forge->addField([
			'version'   => [
				'type'       => 'VARCHAR',
				'constraint' => 255,
				'null'       => false,
			],
			'name'      => [
				'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,
			],
		]);

		$forge->createTable($this->table, true);

		$this->tableChecked = true;
	}

	//--------------------------------------------------------------------
}