Enh improve module migrations (#6550)

* Implement MigrationService: Return and log migration result during enabling, disabling and updating

* Cleanup MIGRATE-DEV.md

* Add `hasMigrations(Pending)()`
This commit is contained in:
Martin Rüegg 2023-12-15 18:30:35 +01:00 committed by GitHub
parent 5d8795fca8
commit 4d142b002b
No known key found for this signature in database
GPG Key ID: 4AEE18F83AFDEB23
21 changed files with 737 additions and 122 deletions

View File

@ -3,6 +3,8 @@ HumHub Changelog
1.16.0 (Unreleased)
-------------------
- Enh #6550: Improve module migrations
- Fix #6237: Migration errors during module activation are ignored
- Enh #6711: run migrations manually
- Enh #6720: Consolidate `isInstalled()`, `setInstalled()`, and `setDatabaseInstalled`
- Fix #6693: `MigrateController::$migrationPathMap` stored rolling sum of migrations

View File

@ -8,11 +8,12 @@ Version 1.16 (Unreleased)
-------------------------
### Deprecations
- `\humhub\components\Module::migrate()` use `getMigrationService()->migrateUp(MigrationService::ACTION_MIGRATE)` instead
- `\humhub\libs\BaseSettingsManager::isDatabaseInstalled()` use `Yii::$app->isDatabaseInstalled()` instead
- `\humhub\models\Setting::isInstalled()` use `Yii::$app->isInstalled()` instead
- `\humhub\modules\content\components\ContentAddonActiveRecord::canRead()` use `canView()` instead
- `\humhub\modules\content\components\ContentAddonActiveRecord::canWrite()`
- `\humhub\modules\file\models\File::canRead()` use `canView()` instead
- `\humhub\modules\content\components\ContentAddonActiveRecord::canRead()` use `canView()` instead
- `\humhub\models\Setting::isInstalled()` use `Yii::$app->isInstalled()` instead
- `\humhub\libs\BaseSettingsManager::isDatabaseInstalled()` use `Yii::$app->isDatabaseInstalled()` instead
### Type restrictions
- `\humhub\components\behaviors\PolymorphicRelation` enforces types on fields, method parameters, & return types

View File

@ -10,8 +10,8 @@ namespace humhub\commands;
use humhub\components\Module;
use humhub\helpers\DatabaseHelper;
use humhub\services\MigrationService;
use Yii;
use yii\base\InvalidRouteException;
use yii\console\Exception;
use yii\db\MigrationInterface;
use yii\web\Application;
@ -207,20 +207,14 @@ class MigrateController extends \yii\console\controllers\MigrateController
* @param \yii\base\Module|null $module Module to get the migrations from, or Null for Application
*
* @return string output
* @throws Exception
* @throws InvalidRouteException
* @deprecated since 1.16; use MigrationService::migrateUp()
* @see MigrationService::migrateUp()
*/
public static function webMigrateAll(string $action = 'up', ?\yii\base\Module $module = null): string
{
ob_start();
$controller = new self('migrate', $module ?? Yii::$app);
$controller->db = Yii::$app->db;
$controller->interactive = false;
$controller->includeModuleMigrations = true;
$controller->color = false;
$controller->runAction($action);
return ob_get_clean() ?: '';
return $action === 'up'
? MigrationService::create($module)->migrateUp()
: MigrationService::create($module)->migrateNew();
}
/**
@ -229,8 +223,11 @@ class MigrateController extends \yii\console\controllers\MigrateController
* @param string $migrationPath
*
* @return string output
* @deprecated since 1.16; use MigrationService::create($module)->migrateUp()
* @see MigrationService::create()
* @see MigrationService::migrateUp()
*/
public static function webMigrateUp(string $migrationPath): string
public static function webMigrateUp(string $migrationPath): ?string
{
ob_start();
$controller = new self('migrate', Yii::$app);
@ -240,7 +237,7 @@ class MigrateController extends \yii\console\controllers\MigrateController
$controller->color = false;
$controller->runAction('up');
return ob_get_clean() ?: '';
return ob_get_clean() ?: null;
}
/**

View File

@ -1,6 +1,6 @@
<?php
/**
/*
* @link https://www.humhub.org/
* @copyright Copyright (c) 2017 HumHub GmbH & Co. KG
* @license https://www.humhub.com/licences
@ -16,7 +16,11 @@ use humhub\modules\file\libs\FileHelper;
use humhub\modules\marketplace\models\Module as OnlineModelModule;
use humhub\modules\notification\components\BaseNotification;
use humhub\modules\queue\helpers\QueueHelper;
use humhub\services\MigrationService;
use Throwable;
use Yii;
use yii\base\InvalidConfigException;
use yii\db\StaleObjectException;
use yii\helpers\Json;
use yii\web\AssetBundle;
@ -239,21 +243,29 @@ class Module extends \yii\base\Module
/**
* Enables this module
*
* @return boolean
* @return bool|null Result of migration or null if beforeEnable() returned false (since v1.16)
* @throws InvalidConfigException
*/
public function enable()
{
Yii::$app->moduleManager->enable($this);
$this->migrate();
$result = $this->getMigrationService()->migrateUp();
return true;
if ($result === false) {
Yii::error('Could not enable module. Database Migration failed! See previous error for result.', $this->id);
return false;
}
Yii::$app->moduleManager->enable($this);
return $result;
}
/**
* Disables a module
*
* This should delete all data created by this module.
* When override this method make sure to invoke call `parent::disable()` **AFTER** your implementation as
* When overriding this method, make sure to invoke call `parent::disable()` **AFTER** your implementation as
*
* ```php
* public function disable()
@ -262,61 +274,40 @@ class Module extends \yii\base\Module
* parent::disable();
* }
* ```
*
* @return bool|null Result uninstall-migration or null if beforeDisable() returned false (since v1.16)
* @throws InvalidConfigException
* @throws StaleObjectException
* @throws Throwable
*/
public function disable()
{
/**
* Remove database tables
*/
$migrationPath = $this->getBasePath() . '/migrations';
$uninstallMigration = $migrationPath . '/uninstall.php';
if (file_exists($uninstallMigration)) {
/**
* Execute Uninstall Migration
*/
ob_start();
require_once($uninstallMigration);
$migration = new \uninstall;
try {
$migration->up();
} catch (\yii\db\Exception $ex) {
Yii::error($ex);
}
ob_get_clean();
/**
* Delete all Migration Table Entries
*/
$migrations = opendir($migrationPath);
$params = [];
while (false !== ($migration = readdir($migrations))) {
if ($migration == '.' || $migration == '..' || $migration == 'uninstall.php') {
continue;
}
$command ??= Yii::$app->db->createCommand()->delete('migration', 'version = :version', $params);
$version = str_replace('.php', '', $migration);
$command->bindValue(':version', $version)->execute();
}
try {
$result = $this->getMigrationService()->uninstall();
ContentContainerSetting::deleteAll(['module_id' => $this->id]);
Setting::deleteAll(['module_id' => $this->id]);
} catch (Throwable $ex) {
Yii::error($ex, $this->id);
$result = false;
}
ContentContainerSetting::deleteAll(['module_id' => $this->id]);
Setting::deleteAll(['module_id' => $this->id]);
Yii::$app->moduleManager->disable($this);
return $result;
}
/**
* Execute all not applied module migrations
* @deprecated since v1.16; use static::getMigrationService()->migrateUp()
*/
public function migrate()
{
$migrationPath = $this->basePath . '/migrations';
if (is_dir($migrationPath)) {
\humhub\commands\MigrateController::webMigrateUp($migrationPath);
}
return $this->getMigrationService()->migrateUp();
}
public function getMigrationService(): MigrationService
{
return new MigrationService($this);
}
/**
@ -342,10 +333,15 @@ class Module extends \yii\base\Module
*/
public function update()
{
if($this->beforeUpdate() !== false) {
$this->migrate();
$this->afterUpdate();
if (!$this->beforeUpdate()) {
return null;
}
$result = $this->getMigrationService()->migrateUp();
$this->afterUpdate();
return $result;
}
/**
@ -360,13 +356,11 @@ class Module extends \yii\base\Module
return true;
}
/**
* Called right after the module update.
*/
public function afterUpdate()
{
}
/**
@ -483,7 +477,7 @@ class Module extends \yii\base\Module
$assetDirectory = $this->getBasePath() . DIRECTORY_SEPARATOR . 'assets';
if (is_dir($assetDirectory)) {
foreach (FileHelper::findFiles($assetDirectory, ['recursive' => false,]) as $file) {
$assetClass = $assetNamespace . '\\' . basename($file, '.php');
$assetClass = $assetNamespace . '\\' . basename($file, '.php');
if (is_subclass_of($assetClass, AssetBundle::class)) {
$assets[] = $assetClass;
}

View File

@ -17,11 +17,14 @@ use humhub\exceptions\InvalidArgumentTypeException;
use humhub\models\ModuleEnabled;
use humhub\modules\admin\events\ModulesEvent;
use humhub\modules\marketplace\Module as ModuleMarketplace;
use Throwable;
use Yii;
use yii\base\Component;
use yii\base\ErrorException;
use yii\base\Event;
use yii\base\Exception;
use yii\base\InvalidConfigException;
use yii\db\StaleObjectException;
use yii\helpers\ArrayHelper;
use yii\helpers\FileHelper;
@ -131,8 +134,8 @@ class ModuleManager extends Component
*
* @param array $configs
*
* @throws InvalidConfigException
* @see \humhub\components\bootstrap\ModuleAutoLoader::bootstrap
* @throws InvalidConfigException Module configuration does not have both an id and class attribute
* @see ModuleAutoLoader::bootstrap
*
*/
public function registerBulk(array $configs)
@ -145,19 +148,19 @@ class ModuleManager extends Component
/**
* Registers a module
*
* @param string $basePath the modules base path
* @param array $config the module configuration (config.php)
* @param string $basePath the module's base path
* @param array|null $config the module configuration (config.php)
*
* @throws InvalidConfigException
* @throws InvalidConfigException Module configuration does not have both an id and class attribute
*/
public function register($basePath, $config = null)
public function register(string $basePath, ?array $config = null)
{
if ($config === null && is_file($filename = $basePath . '/config.php')) {
$config = include $filename;
}
// Check mandatory config options
if (!isset($config['class']) || !isset($config['id'])) {
if (!isset($config['class'], $config['id'])) {
throw new InvalidConfigException('Module configuration requires an id and class attribute: ' . $basePath);
}
@ -345,12 +348,16 @@ class ModuleManager extends Component
$modules = [];
foreach ($this->modules as $id => $class) {
if (!$options['includeCoreModules'] && in_array($class, $this->coreModules)) {
if (!$options['includeCoreModules'] && in_array($class, $this->coreModules, true)) {
// Skip core modules
continue;
}
if ($options['enabled'] && !in_array($class, $this->coreModules) && !in_array($id, $this->enabledModules)) {
if (
$options['enabled']
&& !in_array($class, $this->coreModules, true)
&& !in_array($id, $this->enabledModules, true)
) {
// Skip disabled modules
continue;
}
@ -568,7 +575,7 @@ class ModuleManager extends Component
* @param bool $disableBeforeRemove
*
* @throws Exception
* @throws \yii\base\ErrorException
* @throws ErrorException
*/
public function removeModule($moduleId, $disableBeforeRemove = true): ?string
{
@ -624,6 +631,7 @@ class ModuleManager extends Component
$this->enabledModules[] = $module->id;
$this->register($module->getBasePath());
$this->flushCache();
$this->trigger(static::EVENT_AFTER_MODULE_ENABLE, new ModuleEvent(['module' => $module]));
}
@ -643,8 +651,8 @@ class ModuleManager extends Component
*
* @param Module $module
*
* @throws \Throwable
* @throws \yii\db\StaleObjectException
* @throws Throwable
* @throws StaleObjectException
* @since 1.1
*/
public function disable(Module $module)
@ -662,6 +670,8 @@ class ModuleManager extends Component
Yii::$app->setModule($module->id, null);
$this->flushCache();
$this->trigger(static::EVENT_AFTER_MODULE_DISABLE, new ModuleEvent(['module' => $module]));
}

View File

@ -34,7 +34,7 @@ abstract class SettingActiveRecord extends ActiveRecord
* @param string|array|null $condition
* @param array $params
*
* @return int
* @return int the number of rows deleted
* @noinspection PhpMissingReturnTypeInspection
*/
public static function deleteAll($condition = null, $params = [])

View File

@ -1,4 +1,5 @@
<?php
/**
* @link https://www.humhub.org/
* @copyright Copyright (c) 2017 HumHub GmbH & Co. KG
@ -12,6 +13,7 @@ use Yii;
use yii\base\BootstrapInterface;
use yii\base\ErrorException;
use yii\base\InvalidArgumentException;
use yii\base\InvalidConfigException;
use yii\helpers\FileHelper;
/**
@ -21,13 +23,16 @@ use yii\helpers\FileHelper;
*/
class ModuleAutoLoader implements BootstrapInterface
{
const CACHE_ID = 'module_configs';
const CONFIGURATION_FILE = 'config.php';
public const CACHE_ID = 'module_configs';
public const CONFIGURATION_FILE = 'config.php';
/**
* Bootstrap method to be called during application bootstrap stage.
*
* @param Application $app the application currently running
* @throws \yii\base\InvalidConfigException
*
* @throws InvalidConfigException Module a configuration does not have both an id and class attribute
* @throws ErrorException On invalid module autoload path
*/
public function bootstrap($app)
{
@ -37,10 +42,12 @@ class ModuleAutoLoader implements BootstrapInterface
/**
* Find available modules
* @return array
* @throws ErrorException
*
* @return array[] Array of module configurations with module ID as index.
* Read from cache if available and YII_DEBUG is disabled
* @throws ErrorException On invalid module autoload path
*/
public static function locateModules()
public static function locateModules(): array
{
$modules = Yii::$app->cache->get(self::CACHE_ID);
@ -54,11 +61,13 @@ class ModuleAutoLoader implements BootstrapInterface
/**
* Find all modules with configured paths
* @param array $paths
* @return array
* @throws ErrorException
*
* @param string[] $paths
*
* @return array[] Array of module configurations with module ID as index
* @throws ErrorException On invalid module autoload path
*/
private static function findModules($paths)
private static function findModules(iterable $paths): array
{
$folders = [];
foreach ($paths as $path) {
@ -71,11 +80,12 @@ class ModuleAutoLoader implements BootstrapInterface
$modules = [];
$moduleIdFolders = [];
$preventDuplicatedModules = Yii::$app->moduleManager->preventDuplicatedModules;
foreach ($folders as $folder) {
try {
/** @noinspection PhpIncludeInspection */
$moduleConfig = include $folder . DIRECTORY_SEPARATOR . self::CONFIGURATION_FILE;
if (Yii::$app->moduleManager->preventDuplicatedModules && isset($moduleIdFolders[$moduleConfig['id']])) {
if ($preventDuplicatedModules && isset($moduleIdFolders[$moduleConfig['id']])) {
Yii::error('Duplicated module "' . $moduleConfig['id'] . '"(' . $folder . ') is already loaded from the folder "' . $moduleIdFolders[$moduleConfig['id']] . '"');
} else {
$modules[$folder] = $moduleConfig;
@ -86,10 +96,10 @@ class ModuleAutoLoader implements BootstrapInterface
}
}
if (Yii::$app->moduleManager->preventDuplicatedModules) {
if ($preventDuplicatedModules) {
// Overwrite module paths from config
foreach (Yii::$app->moduleManager->overwriteModuleBasePath as $overwriteModuleId => $overwriteModulePath) {
if (isset($moduleIdFolders[$overwriteModuleId]) && $moduleIdFolders[$overwriteModuleId] != $overwriteModulePath) {
if (isset($moduleIdFolders[$overwriteModuleId]) && $moduleIdFolders[$overwriteModuleId] !== $overwriteModulePath) {
try {
$moduleConfig = include $overwriteModulePath . DIRECTORY_SEPARATOR . self::CONFIGURATION_FILE;
Yii::info('Overwrite path of the module "' . $overwriteModuleId . '" to the folder "' . $overwriteModulePath . '"');
@ -110,13 +120,15 @@ class ModuleAutoLoader implements BootstrapInterface
/**
* Find all directories with a configuration file inside
*
* @param string $path
* @return array
*
* @return string[]
* @throws InvalidArgumentException
*/
private static function findModulesByPath($path)
private static function findModulesByPath(string $path): array
{
$hasConfigurationFile = function ($path) {
$hasConfigurationFile = static function ($path) {
return is_file($path . DIRECTORY_SEPARATOR . self::CONFIGURATION_FILE);
};

View File

@ -0,0 +1,39 @@
<?php
/*
* @link https://www.humhub.org/
* @copyright Copyright (c) 2023 HumHub GmbH & Co. KG
* @license https://www.humhub.com/licences
*/
namespace humhub\events;
use humhub\components\Event;
use humhub\interfaces\ApplicationInterface;
use yii\base\Module;
/**
* @property int|null $result Result of the migration:
* ``
* - `ExitCode::OK`: Success;
* - `ExitCode::UNSPECIFIED_ERROR`: failure;
* - `Null`: nothing done
* ``
*/
class MigrationEvent extends Event
{
/**
* @var \humhub\components\Module|ApplicationInterface|null
*/
public ?Module $module;
/**
* @var string Either `up` or `uninstall`
*/
public string $migration;
/**
* @var string|null Output of the MigrationController's Action
*/
public ?string $output = null;
}

View File

@ -8,7 +8,6 @@
namespace humhub\modules\admin\controllers;
use humhub\commands\MigrateController;
use humhub\libs\SelfTest;
use humhub\modules\admin\components\Controller;
use humhub\modules\admin\components\DatabaseInfo;
@ -18,6 +17,7 @@ use humhub\modules\queue\driver\MySQL;
use humhub\modules\queue\helpers\QueueHelper;
use humhub\modules\queue\interfaces\QueueInfoInterface;
use humhub\modules\search\jobs\RebuildIndex;
use humhub\services\MigrationService;
use ReflectionClass;
use ReflectionException;
use Yii;
@ -89,18 +89,21 @@ class InformationController extends Controller
public function actionDatabase(int $migrate = self::DB_ACTION_CHECK)
{
$migrationService = MigrationService::create();
if ($migrate === self::DB_ACTION_RUN) {
$migrationService->migrateUp();
$migrationOutput = sprintf(
"%s\n%s",
MigrateController::webMigrateAll(),
$migrationService->getLastMigrationOutput(),
SettingController::flushCache()
);
} else {
$migrationOutput = MigrateController::webMigrateAll(MigrateController::MIGRATION_ACTION_NEW);
$migrate = $migrationService->hasMigrationsPending()
? self::DB_ACTION_PENDING
: self::DB_ACTION_CHECK;
$migrate = str_contains($migrationOutput, 'No new migrations found.')
? self::DB_ACTION_CHECK
: self::DB_ACTION_PENDING;
$migrationOutput = $migrationService->getLastMigrationOutput();
}
$databaseInfo = new DatabaseInfo(Yii::$app->db->dsn);

View File

@ -28,7 +28,7 @@ class ContentContainerModule extends Module
/**
* @inheritdoc
*/
public function disable()
public function disable(): ?bool
{
// disable in content containers
$contentContainerQuery = ContentContainerModuleManager::getContentContainerQueryByModule($this->id);
@ -41,7 +41,7 @@ class ContentContainerModule extends Module
$moduleState->delete();
}
parent::disable();
return parent::disable();
}
/**

View File

@ -8,7 +8,6 @@
namespace humhub\modules\installer\commands;
use humhub\commands\MigrateController;
use humhub\helpers\DatabaseHelper;
use humhub\libs\DynamicConfig;
use humhub\libs\UUID;
@ -16,6 +15,7 @@ use humhub\modules\installer\libs\InitialData;
use humhub\modules\user\models\Group;
use humhub\modules\user\models\Password;
use humhub\modules\user\models\User;
use humhub\services\MigrationService;
use Yii;
use yii\base\Exception;
use yii\console\Controller;
@ -89,9 +89,8 @@ class InstallController extends Controller
$this->stdout(" * Installing Database\n", Console::FG_YELLOW);
Yii::$app->cache->flush();
// Disable max execution time to avoid timeouts during migrations
@ini_set('max_execution_time', 0);
MigrateController::webMigrateAll();
MigrationService::create()->migrateUp();
DynamicConfig::rewrite();

View File

@ -8,13 +8,13 @@
namespace humhub\modules\installer\controllers;
use humhub\commands\MigrateController;
use humhub\components\access\ControllerAccess;
use humhub\components\Controller;
use humhub\libs\DynamicConfig;
use humhub\modules\admin\widgets\PrerequisitesList;
use humhub\modules\installer\forms\DatabaseForm;
use humhub\modules\installer\Module;
use humhub\services\MigrationService;
use Yii;
/**
@ -183,11 +183,8 @@ class SetupController extends Controller
// Flush Caches
Yii::$app->cache->flush();
// Disable max execution time to avoid timeouts during database installation
@ini_set('max_execution_time', 0);
// Migrate Up Database
MigrateController::webMigrateAll();
MigrationService::create()->migrateUp();
DynamicConfig::rewrite();

View File

@ -173,7 +173,7 @@ class OnlineModuleManager extends Component
$this->install($moduleId);
$updatedModule = Yii::$app->moduleManager->getModule($moduleId);
$updatedModule->migrate();
$updatedModule->getMigrationService()->migrateUp();
(new MarketplaceService())->refreshPendingModuleUpdateCount();

View File

@ -0,0 +1,339 @@
<?php
/*
* @link https://www.humhub.org/
* @copyright Copyright (c) 2023 HumHub GmbH & Co. KG
* @license https://www.humhub.com/licences
*/
namespace humhub\services;
use humhub\commands\MigrateController;
use humhub\components\Application;
use humhub\components\Event;
use humhub\components\Module;
use humhub\events\MigrationEvent;
use humhub\interfaces\ApplicationInterface;
use humhub\libs\Helpers;
use Throwable;
use Yii;
use yii\base\ActionEvent;
use yii\base\Component;
use yii\base\Controller;
use yii\base\InvalidConfigException;
use yii\base\Module as BaseModule;
use yii\console\ExitCode;
/**
* @since 1.16
*/
class MigrationService extends Component
{
public const EVENT_AFTER_MIGRATION = 'afterMigration';
protected const MIGRATION_NEW = 'new';
protected const MIGRATION_UNINSTALL = 'uninstall';
protected const MIGRATION_UP = 'up';
protected BaseModule $module;
private ?string $path;
private ?int $lastMigrationResult = null;
private ?string $lastMigrationOutput = null;
/**
* @param Module|ApplicationInterface|Application|null $module
*/
public function __construct(?BaseModule $module = null)
{
Helpers::checkClassType($module, [ApplicationInterface::class, Module::class, null]);
$this->module = $module ?? Yii::$app;
parent::__construct();
}
public function init()
{
parent::init();
/**
* Since for console application the id is set to 'humhub-console' and might be configured for the application too,
* we need to use the hard-coded 'humhub' string for non-modules.
*
* @see \humhub\components\console\Application::$id
* @see protected/humhub/config/console.php
*/
$moduleId = $this->module instanceof Module
? $this->module->id
: 'humhub';
$this->path = "@$moduleId/migrations";
$realpath = $this->getPath(true);
if ($realpath === false || !is_dir($realpath)) {
Yii::debug("Module has no migrations directory.", $this->module->id);
$this->path = null;
}
}
/**
* @return Module|ApplicationInterface|Application|null
*/
public function getModule(): BaseModule
{
return $this->module;
}
private function getPath(bool $resolve = false): ?string
{
if (!$resolve || $this->path === null) {
return $this->path;
}
$path = realpath(Yii::getAlias($this->path));
if ($path === false) {
return null;
}
return $path;
}
public function getLastMigrationOutput(): ?string
{
return $this->lastMigrationOutput;
}
public function getLastMigrationResult(): ?int
{
return $this->lastMigrationResult;
}
public function hasMigrations(): bool
{
return $this->path !== null;
}
public function hasMigrationsPending(): bool
{
if (!$this->hasMigrations()) {
return false;
}
if ($this->migrateNew() === false) {
return false;
}
$migrationOutput = $this->getLastMigrationOutput();
return !str_contains($migrationOutput, 'No new migrations found.');
}
/**
* Run migrations.
*/
public function migrateNew(): ?bool
{
return $this->migrate(MigrationService::MIGRATION_NEW);
}
/**
* Check for pending migrations
*/
public function migrateUp(): ?bool
{
return $this->migrate(MigrationService::MIGRATION_UP);
}
/**
* @param string $action Must be MigrationService::MIGRATION_ACTION_UP to run migrations,
* or MigrationService::MIGRATION_ACTION_NEW to check for pending migrations
*
* @return bool|null
*/
private function migrate(string $action): ?bool
{
$result = $this->checkMigrationBefore($action);
if ($result === null) {
return null;
}
// this event is collecting the migration's result status and storing it in our event
Event::on(
MigrateController::class,
Controller::EVENT_AFTER_ACTION,
[
$this,
'onMigrationControllerAfterAction'
],
$result
);
// Disable max execution time to avoid timeouts during migrations
@ini_set('max_execution_time', 0);
$module = $this->getModule();
ob_start();
$controller = new MigrateController('migrate', $module, [
'db' => Yii::$app->db,
'interactive' => false,
'color' => false,
'migrationPath' => $this->getPath(),
'includeModuleMigrations' => true,
]);
/** @noinspection PhpUnhandledExceptionInspection */
$controller->runAction($action);
$result->output = ob_get_clean() ?: null;
// we no longer need to listen to this event
Event::off(
MigrateController::class,
Controller::EVENT_AFTER_ACTION,
[
$this,
'onMigrationControllerAfterAction'
]
);
return $this->checkMigrationStatus($result);
}
/**
* Catches migration results.
*
* @internal
*/
public function onMigrationControllerAfterAction(ActionEvent $event)
{
if (!$event->sender instanceof MigrateController) {
return;
}
if (!$event->data instanceof MigrationEvent || $event->data->sender !== $this) {
return;
}
$event->data->result = $event->result ?? ExitCode::UNSPECIFIED_ERROR;
}
private function checkMigrationBefore(string $migrationAction): ?MigrationEvent
{
$this->lastMigrationOutput = null;
$this->lastMigrationResult = null;
if (!$this->hasMigrations()) {
return null;
}
return new MigrationEvent([
'sender' => $this,
'module' => $this->getModule(),
'migration' => $migrationAction,
]);
}
/**
* @param MigrationEvent $result
*
* @return bool
* @throws InvalidConfigException
* @throws Throwable
*/
private function checkMigrationStatus(MigrationEvent $result): bool
{
$this->lastMigrationOutput = $result->output ?: 'Migration output unavailable';
$this->lastMigrationResult = $result->result;
/** @see \yii\console\controllers\BaseMigrateController::actionUp() */
if ($result->result > ExitCode::OK) {
Yii::error($this->lastMigrationOutput, $this->module->id);
} else {
Yii::info($this->lastMigrationOutput, $this->module->id);
}
$this->trigger(self::EVENT_AFTER_MIGRATION, $result);
/** @see \yii\console\controllers\BaseMigrateController::actionUp() */
if ($result->result > ExitCode::OK) {
$errorMessage = "Migration failed!";
if (YII_DEBUG) {
throw new InvalidConfigException($errorMessage);
}
Yii::error($errorMessage, $this->module->id);
return false;
}
return true;
}
public function uninstall(): ?bool
{
$result = $this->checkMigrationBefore(self::MIGRATION_UNINSTALL);
if ($result === null) {
return null;
}
$path = $this->getPath(true);
$uninstallMigration = $path . '/uninstall.php';
if (!file_exists($uninstallMigration)) {
Yii::warning("Module has no uninstall migration!", $this->module->id);
return null;
}
/**
* Execute Uninstall Migration
*/
ob_start();
require_once($uninstallMigration);
$migration = new \uninstall();
$migration->compact = false;
try {
$result->result = $migration->up() === false ? ExitCode::UNSPECIFIED_ERROR : ExitCode::OK;
} catch (\yii\db\Exception $ex) {
Yii::error($ex, $this->module->id);
$result->result = ExitCode::UNSPECIFIED_ERROR;
}
$result->output = ob_get_clean();
/**
* Delete all Migration Table Entries
*/
$migrations = opendir($path);
$params = [];
while (false !== ($migration = readdir($migrations))) {
if ($migration === '.' || $migration === '..' || $migration === 'uninstall.php') {
continue;
}
$command ??= Yii::$app->db->createCommand()->delete('migration', 'version = :version', $params);
$version = str_replace('.php', '', $migration);
$command->bindValue(':version', $version)->execute();
$result->output .= " > migration entry $version removed.\n";
}
return $this->checkMigrationStatus($result);
}
/**
* @param Module|ApplicationInterface|Application|null $module
*
* @noinspection PhpDocMissingThrowsInspection
*/
public static function create(?BaseModule $module = null): self
{
/** @noinspection PhpUnhandledExceptionInspection */
return Yii::createObject(static::class, [$module]);
}
}

View File

@ -0,0 +1,27 @@
<?php
/*
* @link https://www.humhub.org/
* @copyright Copyright (c) 2023 HumHub GmbH & Co. KG
* @license https://www.humhub.com/licences
*/
namespace Some\Name\Space\moduleWithMigration;
class Module extends \humhub\components\Module
{
public const ID = 'moduleWithMigration';
public const NAMESPACE = __NAMESPACE__;
public bool $doEnable = true;
public bool $doDisable = true;
public function beforeEnable(): bool
{
return $this->doEnable;
}
public function beforeDisable(): bool
{
return $this->doDisable;
}
}

View File

@ -0,0 +1,27 @@
<?php
/*
* @link https://www.humhub.org/
* @copyright Copyright (c) 2023 HumHub GmbH & Co. KG
* @license https://www.humhub.com/licences
*/
/** @noinspection MissedFieldInspection */
require_once __DIR__ . "/Module.php";
return [
'id' => 'moduleWithMigration',
'class' => \Some\Name\Space\moduleWithMigration\Module::class,
'namespace' => "Some\\Name\\Space\\moduleWithMigration",
'events' => [
[
'class' => \humhub\tests\codeception\unit\components\ModuleManagerTest::class,
'event' => 'valid',
'callback' => [
\humhub\tests\codeception\unit\components\ModuleManagerTest::class,
'handleEvent',
],
],
]
];

View File

@ -0,0 +1,31 @@
<?php
/*
* @link https://www.humhub.org/
* @copyright Copyright (c) 2023 HumHub GmbH & Co. KG
* @license https://www.humhub.com/licences
*/
use humhub\components\Migration;
class m230911_000100_create_test_table extends Migration
{
// protected properties
protected string $table = 'test_module_with_migration';
/**
* {@inheritdoc}
*/
public function safeUp()
{
$this->safeCreateTable($this->table, [
'id' => $this->primaryKey(),
'created_by' => $this->integerReferenceKey(),
'created_at' => $this->timestampWithoutAutoUpdate()
->notNull(),
]);
// add foreign key for table `user`
$this->safeAddForeignKeyCreatedBy();
}
}

View File

@ -0,0 +1,38 @@
<?php
/*
* @link https://www.humhub.org/
* @copyright Copyright (c) 2023 HumHub GmbH & Co. KG
* @license https://www.humhub.com/licences
*/
use humhub\components\Migration;
/**
* Class uninstall
*/
class uninstall extends Migration
{
/**
* {@inheritdoc}
*/
public function safeUp()
{
// enable output
$this->compact = false;
$this->safeDropTable('test_module_with_migration');
return true;
}
/**
* {@inheritdoc}
*/
public function safeDown()
{
echo "uninstall cannot be reverted.\n";
return false;
}
}

View File

@ -0,0 +1,25 @@
{
"id": "example",
"version": "1.0",
"name": "My Example Module With Migration",
"description": "My testing module with migration",
"humhub": {
"minVersion": "1.2"
},
"keywords": ["valid", "migration"],
"homepage": "https://www.example.com",
"authors": [
{
"name": "Tom Coder",
"email": "tc@example.com",
"role": "Developer"
},
{
"name": "Sarah Mustermann",
"email": "sm@example.com",
"homepage": "http://example.com",
"role": "Translator"
}
],
"licence": "AGPL-3.0-or-later"
}

View File

@ -19,6 +19,7 @@ use humhub\modules\admin\events\ModulesEvent;
use humhub\tests\codeception\unit\ModuleAutoLoaderTest;
use Some\Name\Space\module1\Module as Module1;
use Some\Name\Space\module2\Module as Module2;
use Some\Name\Space\moduleWithMigration\Module as ModuleWithMigration;
use tests\codeception\_support\HumHubDbTestCase;
use Yii;
use yii\base\ErrorException;
@ -26,8 +27,10 @@ use yii\base\Event;
use yii\base\Exception;
use yii\base\InvalidConfigException;
use yii\caching\ArrayCache;
use yii\console\ExitCode;
use yii\db\StaleObjectException;
use yii\helpers\FileHelper;
use yii\log\Logger;
require_once __DIR__ . '/bootstrap/ModuleAutoLoaderTest.php';
@ -582,7 +585,10 @@ class ModuleManagerTest extends HumHubDbTestCase
public function testGetModules()
{
$moduleManager = Yii::$app->moduleManager;
static::assertIsArray($modules = $moduleManager->getModules(['returnClass' => true]));
$modules = $moduleManager->getModules(['returnClass' => true]);
static::assertIsArray($modules);
static::assertCount(static::$moduleDirCount, $modules);
}
@ -598,13 +604,23 @@ class ModuleManagerTest extends HumHubDbTestCase
$module = $this->registerModule($basePath, $config);
$oldMM = Yii::$app->cache;
$oldMM = Yii::$app->moduleManager;
Yii::$app->set('moduleManager', $this->moduleManager);
static::logInitialize();
$this->moduleManager->enableModules([$module, static::$testModuleRoot . '/module2']);
Yii::$app->set('moduleManager', $oldMM);
static::assertNotLog('Module has not been enabled due to beforeEnable() returning false', Logger::LEVEL_WARNING, [$module->id]);
static::assertLog('Module has no migrations directory.', Logger::LEVEL_TRACE, [$module->id]);
static::assertNotLog('Module has not been enabled due to beforeEnable() returning false', Logger::LEVEL_WARNING, ['module2']);
static::assertLogRegex('@No new migrations found\. Your system is up-to-date\.@', Logger::LEVEL_INFO, ['module2']);
static::logReset();
/** @noinspection MissedFieldInspection */
$this->assertEvents([
[
@ -633,6 +649,63 @@ class ModuleManagerTest extends HumHubDbTestCase
$this->moduleManager->enableModules([static::$testModuleRoot . '/non-existing-module']);
}
/**
* @noinspection MissedFieldInspection
*/
public function testEnableModulesWithMigration()
{
Yii::$app->set('moduleManager', $this->moduleManager);
$this->moduleManager->on(ModuleManager::EVENT_AFTER_MODULE_ENABLE, [$this, 'handleEvent']);
/** @var ModuleWithMigration $module */
$module = $this->moduleManager->getModule(static::$testModuleRoot . '/moduleWithMigration');
static::logInitialize();
// ToDo: beforeEnable() has been removed from this PR and will be re-introduced as an event in a follow-up PR
// $module->doEnable = false;
// static::assertNull($module->enable());
// static::assertNull($module->migrationResult);
// static::assertNull($module->migrationOutput);
// $this->assertEvents();
// static::assertLog('Module has not been enabled due to beforeEnable() returning false', Logger::LEVEL_WARNING, [$module->id]);
// static::logFlush();
$module->doEnable = true;
static::assertTrue($module->enable());
// static::assertEquals(ExitCode::OK, $module->migrationResult);
// static::assertNotLog('Module has not been enabled due to beforeEnable() returning false', Logger::LEVEL_WARNING, [$module->id]);
// static::assertLogRegex('@\*\*\* applied m230911_000100_create_test_table \(time: \d+\.\d+s\)@', Logger::LEVEL_INFO, [$module->id]);
static::logFlush();
$this->assertEvents([
[
'class' => ModuleEvent::class,
'event' => 'afterModuleEnabled',
'sender' => $this->moduleManager,
'data' => null,
'handled' => false,
'module' => ['moduleWithMigration' => ModuleWithMigration::class],
],
]);
// $module->doDisable = false;
// static::assertNull($module->disable());
// static::assertNull($module->migrationResult);
// static::assertNull($module->migrationOutput);
// $this->assertEvents();
//
// static::assertLog('Module has not been disabled due to beforeDisable() returning false', Logger::LEVEL_WARNING, [$module->id]);
// static::logFlush();
$module->doDisable = true;
static::assertTrue($module->disable());
// static::assertEquals(ExitCode::OK, $module->migrationResult);
// static::assertNotLog('Module has not been enabled due to beforeEnable() returning false', Logger::LEVEL_WARNING, [$module->id]);
// static::assertLogRegex('@ > drop table test_module_with_migration \.\.\. done \(time: \d+\.\d+s\)@', Logger::LEVEL_INFO, [$module->id]);
static::logFlush();
}
/**
* @throws InvalidConfigException
*/
@ -1014,10 +1087,11 @@ class ModuleManagerTest extends HumHubDbTestCase
'module_id' => [
'module1',
'module2',
'moduleWithMigration',
'coreModule',
'installerModule',
'invalidModule1',
'invalidModule2'
'invalidModule2',
]
]);