winter/modules/system/classes/VersionManager.php
Ben Thomson c5dfeffd41 Fix migrations not being run if notes output is not provided.
Fixes an issue in the Builder plugin where tables cannot be created as the migration is not running on save.
2022-08-22 12:50:23 +08:00

715 lines
21 KiB
PHP

<?php namespace System\Classes;
use File;
use Yaml;
use Db;
use Carbon\Carbon;
use Illuminate\Console\View\Components\Error;
use Illuminate\Console\View\Components\Info;
use Illuminate\Console\View\Components\Task;
use Winter\Storm\Database\Updater;
/**
* Version manager
*
* Manages the versions and database updates for plugins.
*
* @package winter\wn-system-module
* @author Alexey Bobkov, Samuel Georges
*/
class VersionManager
{
use \Winter\Storm\Support\Traits\Singleton;
/**
* Value when no updates are found.
*/
const NO_VERSION_VALUE = 0;
/**
* Morph types for history table.
*/
const HISTORY_TYPE_COMMENT = 'comment';
const HISTORY_TYPE_SCRIPT = 'script';
/**
* @var \Illuminate\Console\OutputStyle
*/
protected $notesOutput;
/**
* Cache of plugin versions as files.
*/
protected $fileVersions;
/**
* Cache of database versions
*/
protected $databaseVersions;
/**
* Cache of database history
*/
protected $databaseHistory;
/**
* @var Winter\Storm\Database\Updater
*/
protected $updater;
/**
* @var System\Classes\PluginManager
*/
protected $pluginManager;
protected function init()
{
$this->updater = new Updater;
$this->pluginManager = PluginManager::instance();
}
/**
* Updates a single plugin by its code or object with it's latest changes.
* If the $stopAfterVersion parameter is specified, the process stops after
* the specified version is applied.
*/
public function updatePlugin($plugin, $stopAfterVersion = null)
{
$code = is_string($plugin) ? $plugin : $this->pluginManager->getIdentifier($plugin);
if (!$this->hasVersionFile($code)) {
return false;
}
$currentVersion = $this->getLatestFileVersion($code);
$databaseVersion = $this->getDatabaseVersion($code);
$this->out('', true);
// No updates needed
if ($currentVersion === (string) $databaseVersion) {
$this->write(Info::class, 'Nothing to migrate.');
return;
}
$newUpdates = $this->getNewFileVersions($code, $databaseVersion);
$this->write(Info::class, 'Running migrations.');
foreach ($newUpdates as $version => $details) {
$this->applyPluginUpdate($code, $version, $details);
if ($stopAfterVersion === $version) {
return true;
}
}
$this->out('', true);
return true;
}
/**
* Update the current replaced plugin's version to reference the replacing plugin.
*/
public function replacePlugin(PluginBase $plugin, string $replace)
{
$currentVersion = $this->getDatabaseVersion($replace);
if ($currentVersion === self::NO_VERSION_VALUE) {
return;
}
// We only care about the database version of the replaced plugin at this point
if (!$plugin->canReplacePlugin($replace, $currentVersion)) {
return;
}
$code = $plugin->getPluginIdentifier();
// Replace existing migration information with the new identifier
if ($versions = $this->getOldFileVersions($code, $currentVersion)) {
foreach ($versions as $version => $details) {
list($comments, $scripts) = $this->extractScriptsAndComments($details);
$now = now()->toDateTimeString();
foreach ($scripts as $script) {
Db::table('system_plugin_history')->insert([
'code' => $code,
'type' => self::HISTORY_TYPE_SCRIPT,
'version' => $version,
'detail' => $script,
'created_at' => $now,
]);
}
foreach ($comments as $comment) {
$this->applyDatabaseComment($code, $version, $comment);
}
}
// delete replaced plugin history
Db::table('system_plugin_history')->where('code', $replace)->delete();
// replace installed version
Db::table('system_plugin_versions')
->where('code', '=', $replace)
->update([
'code' => $code
]);
}
}
/**
* Returns a list of unapplied plugin versions.
*/
public function listNewVersions($plugin)
{
$code = is_string($plugin) ? $plugin : $this->pluginManager->getIdentifier($plugin);
if (!$this->hasVersionFile($code)) {
return [];
}
$databaseVersion = $this->getDatabaseVersion($code);
return $this->getNewFileVersions($code, $databaseVersion);
}
/**
* Applies a single version update to a plugin.
*/
protected function applyPluginUpdate($code, $version, $details)
{
list($comments, $scripts) = $this->extractScriptsAndComments($details);
$updateFn = function () use ($code, $version, $comments, $scripts) {
/*
* Apply scripts, if any
*/
foreach ($scripts as $script) {
if ($this->hasDatabaseHistory($code, $version, $script)) {
continue;
}
$this->applyDatabaseScript($code, $version, $script);
}
/*
* Register the comment and update the version
*/
if (!$this->hasDatabaseHistory($code, $version)) {
foreach ($comments as $comment) {
$this->applyDatabaseComment($code, $version, $comment);
}
}
$this->setDatabaseVersion($code, $version);
};
if (is_null($this->notesOutput)) {
$updateFn();
return;
}
$this->write(Task::class, sprintf(
'<info>%s</info>%s',
str_pad($version . ':', 10),
(strlen($comments[0]) > 120) ? substr($comments[0], 0, 120) . '...' : $comments[0]
), $updateFn);
}
/**
* Removes and packs down a plugin from the system. Files are left intact.
* If the $stopOnVersion parameter is specified, the process stops after
* the specified version is rolled back.
*
* @param mixed $plugin Either the identifier of a plugin as a string, or a Plugin class.
* @param string $stopOnVersion
* @param bool $stopCurrentVersion
* @return bool
*/
public function removePlugin($plugin, $stopOnVersion = null, $stopCurrentVersion = false)
{
$code = is_string($plugin) ? $plugin : $this->pluginManager->getIdentifier($plugin);
if (!$this->hasVersionFile($code)) {
return false;
}
$pluginHistory = $this->getDatabaseHistory($code);
$pluginHistory = array_reverse($pluginHistory);
$stopOnNextVersion = false;
$newPluginVersion = null;
try {
foreach ($pluginHistory as $history) {
if ($stopCurrentVersion && $stopOnVersion === $history->version) {
$newPluginVersion = $history->version;
break;
}
if ($stopOnNextVersion && $history->version !== $stopOnVersion) {
// Stop if the $stopOnVersion value was found and
// this is a new version. The history could contain
// multiple items for a single version (comments and scripts).
$newPluginVersion = $history->version;
break;
}
if ($history->type == self::HISTORY_TYPE_COMMENT) {
$this->removeDatabaseComment($code, $history->version);
} elseif ($history->type == self::HISTORY_TYPE_SCRIPT) {
$this->removeDatabaseScript($code, $history->version, $history->detail);
}
if ($stopOnVersion === $history->version) {
$stopOnNextVersion = true;
}
}
} catch (\Exception $exception) {
$lastHistory = $this->getLastHistory($code);
if ($lastHistory) {
$this->setDatabaseVersion($code, $lastHistory->version);
}
throw $exception;
}
$this->setDatabaseVersion($code, $newPluginVersion);
if (isset($this->fileVersions[$code])) {
unset($this->fileVersions[$code]);
}
if (isset($this->databaseVersions[$code])) {
unset($this->databaseVersions[$code]);
}
if (isset($this->databaseHistory[$code])) {
unset($this->databaseHistory[$code]);
}
return true;
}
/**
* Deletes all records from the version and history tables for a plugin.
* @param string $pluginCode Plugin code
* @return void
*/
public function purgePlugin($pluginCode)
{
$versions = Db::table('system_plugin_versions')->where('code', $pluginCode);
if ($countVersions = $versions->count()) {
$versions->delete();
}
$history = Db::table('system_plugin_history')->where('code', $pluginCode);
if ($countHistory = $history->count()) {
$history->delete();
}
return ($countHistory + $countVersions) > 0;
}
//
// File representation
//
/**
* Returns the latest version of a plugin from its version file.
*/
protected function getLatestFileVersion($code)
{
$versionInfo = $this->getFileVersions($code);
if (!$versionInfo) {
return self::NO_VERSION_VALUE;
}
return trim(key(array_slice($versionInfo, -1, 1)));
}
/**
* Returns older versions up to a supplied version, ie. applied versions.
*/
protected function getOldFileVersions($code, $version = null)
{
if ($version === null) {
$version = self::NO_VERSION_VALUE;
}
$versions = $this->getFileVersions($code);
$position = array_search($version, array_keys($versions));
return array_slice($versions, 0, ++$position);
}
/**
* Returns any new versions from a supplied version, ie. unapplied versions.
*/
protected function getNewFileVersions($code, $version = null)
{
if ($version === null) {
$version = self::NO_VERSION_VALUE;
}
$versions = $this->getFileVersions($code);
$position = array_search($version, array_keys($versions), true);
return array_slice($versions, ++$position);
}
/**
* Returns all versions of a plugin from its version file.
*/
protected function getFileVersions($code)
{
if ($this->fileVersions !== null && array_key_exists($code, $this->fileVersions)) {
return $this->fileVersions[$code];
}
$versionFile = $this->getVersionFile($code);
$versionInfo = Yaml::withProcessor(new VersionYamlProcessor, function ($yaml) use ($versionFile) {
return $yaml->parseFile($versionFile);
});
if (!is_array($versionInfo)) {
$versionInfo = [];
}
$normalizedVersions = [];
foreach ($versionInfo as $version => $info) {
$normalizedVersions[$this->normalizeVersion($version)] = $info;
}
if ($normalizedVersions) {
uksort($normalizedVersions, function ($a, $b) {
return version_compare($a, $b);
});
}
return $this->fileVersions[$code] = $normalizedVersions;
}
/**
* Normalize a version identifier by removing the optional 'v' prefix
*/
protected function normalizeVersion(string $version): string
{
return ltrim($version, 'v');
}
/**
* Returns the absolute path to a version file for a plugin.
*/
protected function getVersionFile($code)
{
$versionFile = $this->pluginManager->getPluginPath($code) . '/updates/version.yaml';
return $versionFile;
}
/**
* Checks if a plugin has a version file.
*/
protected function hasVersionFile($code)
{
$versionFile = $this->getVersionFile($code);
return File::isFile($versionFile);
}
//
// Database representation
//
/**
* Returns the latest version of a plugin from the database.
*/
protected function getDatabaseVersion($code)
{
if ($this->databaseVersions === null) {
$this->databaseVersions = Db::table('system_plugin_versions')->lists('version', 'code');
}
if (!isset($this->databaseVersions[$code])) {
$this->databaseVersions[$code] = Db::table('system_plugin_versions')
->where('code', $code)
->value('version');
}
return $this->normalizeVersion((string) ($this->databaseVersions[$code] ?? self::NO_VERSION_VALUE));
}
/**
* Updates a plugin version in the database.
*/
protected function setDatabaseVersion($code, $version = null)
{
$currentVersion = $this->getDatabaseVersion($code);
if ($version && !$currentVersion) {
Db::table('system_plugin_versions')->insert([
'code' => $code,
'version' => $version,
'created_at' => new Carbon
]);
} elseif ($version && $currentVersion) {
Db::table('system_plugin_versions')->where('code', $code)->update([
'version' => $version,
'created_at' => new Carbon
]);
} elseif ($currentVersion) {
Db::table('system_plugin_versions')->where('code', $code)->delete();
}
$this->databaseVersions[$code] = $version;
}
/**
* Registers a database update comment in the history table.
*/
protected function applyDatabaseComment($code, $version, $comment)
{
Db::table('system_plugin_history')->insert([
'code' => $code,
'type' => self::HISTORY_TYPE_COMMENT,
'version' => $version,
'detail' => $comment,
'created_at' => new Carbon
]);
}
/**
* Removes a database update comment in the history table.
*/
protected function removeDatabaseComment($code, $version)
{
Db::table('system_plugin_history')
->where('code', $code)
->where('type', self::HISTORY_TYPE_COMMENT)
->where('version', $version)
->delete();
}
/**
* Registers a database update script in the history table.
*/
protected function applyDatabaseScript($code, $version, $script)
{
/*
* Execute the database PHP script
*/
$updateFile = $this->pluginManager->getPluginPath($code) . '/updates/' . $script;
if (!File::isFile($updateFile)) {
$this->write(Error::class, sprintf('Migration file "%s" not found.', $script));
return;
}
$this->updater->setUp($updateFile);
Db::table('system_plugin_history')->insert([
'code' => $code,
'type' => self::HISTORY_TYPE_SCRIPT,
'version' => $version,
'detail' => $script,
'created_at' => new Carbon
]);
}
/**
* Removes a database update script in the history table.
*/
protected function removeDatabaseScript($code, $version, $script)
{
/*
* Execute the database PHP script
*/
$updateFile = $this->pluginManager->getPluginPath($code) . '/updates/' . $script;
$this->updater->packDown($updateFile);
Db::table('system_plugin_history')
->where('code', $code)
->where('type', self::HISTORY_TYPE_SCRIPT)
->where('version', $version)
->where('detail', $script)
->delete();
}
/**
* Returns all the update history for a plugin.
*/
public function getDatabaseHistory($code)
{
if ($this->databaseHistory !== null && array_key_exists($code, $this->databaseHistory)) {
return $this->databaseHistory[$code];
}
$historyInfo = Db::table('system_plugin_history')
->where('code', $code)
->orderBy('id')
->get()
->all();
return $this->databaseHistory[$code] = $historyInfo;
}
/**
* Returns the last update history for a plugin.
*
* @param string $code The plugin identifier
* @return stdClass|null
*/
protected function getLastHistory($code)
{
return Db::table('system_plugin_history')
->where('code', $code)
->orderBy('id', 'DESC')
->first();
}
/**
* Checks if a plugin has an applied update version.
*/
protected function hasDatabaseHistory($code, $version, $script = null)
{
$historyInfo = $this->getDatabaseHistory($code);
if (!$historyInfo) {
return false;
}
foreach ($historyInfo as $history) {
if ($history->version != $version) {
continue;
}
if ($history->type == self::HISTORY_TYPE_COMMENT && !$script) {
return true;
}
if ($history->type == self::HISTORY_TYPE_SCRIPT && $history->detail == $script) {
return true;
}
}
return false;
}
//
// Notes
//
/**
* Writes output to the console using a Laravel CLI View component.
*
* @param \Illuminate\Console\View\Components\Component $component
* @param array $arguments
* @return static
*/
protected function write($component, ...$arguments)
{
if ($this->notesOutput !== null) {
with(new $component($this->notesOutput))->render(...$arguments);
}
return $this;
}
/**
* Writes output to the console.
*
* @param string $message
* @param bool $newline
* @return static
*/
protected function out($message, $newline = false)
{
if ($this->notesOutput !== null) {
$this->notesOutput->write($message, $newline);
}
return $this;
}
/**
* Sets an output stream for writing notes.
* @param Illuminate\Console\Command $output
* @return self
*/
public function setNotesOutput($output)
{
$this->notesOutput = $output;
return $this;
}
/**
* Extract script and comments from version details
* @return array
*/
protected function extractScriptsAndComments($details): array
{
if (is_array($details)) {
$fileNamePattern = "/^[a-z0-9\_\-\.\/\\\]+\.php$/i";
$comments = array_values(array_filter($details, function ($detail) use ($fileNamePattern) {
return !preg_match($fileNamePattern, $detail);
}));
$scripts = array_values(array_filter($details, function ($detail) use ($fileNamePattern) {
return preg_match($fileNamePattern, $detail);
}));
}
else {
$comments = (array)$details;
$scripts = [];
}
return [$comments, $scripts];
}
/**
* Get the currently installed version of the plugin.
*
* @param string|PluginBase $plugin Either the identifier of a plugin as a string, or a Plugin class.
* @return string
*/
public function getCurrentVersion($plugin): string
{
$code = $this->pluginManager->getIdentifier($plugin);
return $this->getDatabaseVersion($code);
}
/**
* Check if a certain version of the plugin exists in the plugin history database.
*
* @param string|PluginBase $plugin Either the identifier of a plugin as a string, or a Plugin class.
* @param string $version
* @return bool
*/
public function hasDatabaseVersion($plugin, string $version): bool
{
$code = $this->pluginManager->getIdentifier($plugin);
$histories = $this->getDatabaseHistory($code);
foreach ($histories as $history) {
if ($history->version === $version) {
return true;
}
}
return false;
}
/**
* Get last version note
*
* @param string|PluginBase $plugin
* @return string
*/
public function getCurrentVersionNote($plugin): string
{
$code = $this->pluginManager->getIdentifier($plugin);
$histories = $this->getDatabaseHistory($code);
$lastHistory = array_last(array_where($histories, function ($history) {
return $history->type === self::HISTORY_TYPE_COMMENT;
}));
return $lastHistory ? $lastHistory->detail : '';
}
}