From 4ff3848262488cfd22d916af942bb5b34ba98d69 Mon Sep 17 00:00:00 2001 From: Alex Pott <alex.a.pott@googlemail.com> Date: Tue, 10 Mar 2020 16:10:29 +0000 Subject: [PATCH] Issue #3066801 by catch, WidgetsBurritos, pobster, jungle, tim.plunkett, xjm, dww, alexpott, webchick, benjifisher, longwave, worldlinemine, lauriii, Berdir: Add hook_removed_post_updates() --- .../Drupal/Core/Extension/ModuleInstaller.php | 7 +- core/lib/Drupal/Core/Extension/module.api.php | 25 ++++ .../Update/RemovedPostUpdateNameException.php | 12 ++ .../lib/Drupal/Core/Update/UpdateRegistry.php | 25 +++- core/modules/system/system.install | 30 ++++ .../update_test_postupdate.post_update.php | 12 ++ .../Module/InstallUninstallTest.php | 11 +- .../UpdateSystem/UpdatePostUpdateTest.php | 13 ++ .../UpdateRemovedPostUpdateTest.php | 133 ++++++++++++++++++ .../Tests/Core/Update/UpdateRegistryTest.php | 66 +++++++++ 10 files changed, 329 insertions(+), 5 deletions(-) create mode 100644 core/lib/Drupal/Core/Update/RemovedPostUpdateNameException.php create mode 100644 core/modules/system/tests/src/Functional/UpdateSystem/UpdateRemovedPostUpdateTest.php diff --git a/core/lib/Drupal/Core/Extension/ModuleInstaller.php b/core/lib/Drupal/Core/Extension/ModuleInstaller.php index 70157580d4cc..17bb70030623 100644 --- a/core/lib/Drupal/Core/Extension/ModuleInstaller.php +++ b/core/lib/Drupal/Core/Extension/ModuleInstaller.php @@ -295,10 +295,13 @@ public function install(array $module_list, $enable_dependencies = TRUE) { } drupal_set_installed_schema_version($module, $version); - // Ensure that all post_update functions are registered already. + // Ensure that all post_update functions are registered already. This + // should include existing post-updates, as well as any specified as + // having been previously removed, to ensure that newly installed and + // updated sites have the same entries in the registry. /** @var \Drupal\Core\Update\UpdateRegistry $post_update_registry */ $post_update_registry = \Drupal::service('update.post_update_registry'); - $post_update_registry->registerInvokedUpdates($post_update_registry->getModuleUpdateFunctions($module)); + $post_update_registry->registerInvokedUpdates(array_merge($post_update_registry->getModuleUpdateFunctions($module), array_keys($post_update_registry->getRemovedPostUpdates($module)))); // Record the fact that it was installed. $modules_installed[] = $module; diff --git a/core/lib/Drupal/Core/Extension/module.api.php b/core/lib/Drupal/Core/Extension/module.api.php index e55804d2c264..7c02338233e1 100644 --- a/core/lib/Drupal/Core/Extension/module.api.php +++ b/core/lib/Drupal/Core/Extension/module.api.php @@ -745,6 +745,7 @@ function hook_update_N(&$sandbox) { * @ingroup update_api * * @see hook_update_N() + * @see hook_removed_post_updates() */ function hook_post_update_NAME(&$sandbox) { // Example of updating some content. @@ -763,6 +764,30 @@ function hook_post_update_NAME(&$sandbox) { return $result; } +/** + * Return an array of removed hook_post_update_NAME() function names. + * + * This should be used to indicate post-update functions that have existed in + * some previous version of the module, but are no longer available. + * + * This implementation has to be placed in a MODULE.post_update.php file. + * + * @return string[] + * An array where the keys are removed post-update function names, and the + * values are the first stable version in which the update was removed. + * + * @ingroup update_api + * + * @see hook_post_update_NAME() + */ +function hook_removed_post_updates() { + return [ + 'mymodule_post_update_foo' => '8.x-2.0', + 'mymodule_post_update_bar' => '8.x-3.0', + 'mymodule_post_update_baz' => '8.x-3.0', + ]; +} + /** * Return an array of information about module update dependencies. * diff --git a/core/lib/Drupal/Core/Update/RemovedPostUpdateNameException.php b/core/lib/Drupal/Core/Update/RemovedPostUpdateNameException.php new file mode 100644 index 000000000000..33fe314ac98f --- /dev/null +++ b/core/lib/Drupal/Core/Update/RemovedPostUpdateNameException.php @@ -0,0 +1,12 @@ +<?php + +namespace Drupal\Core\Update; + +/** + * An exception thrown for removed post-update functions. + * + * Occurs when a module defines hook_post_update_NAME() implementations + * that are listed as removed in hook_removed_post_updates(). + */ +class RemovedPostUpdateNameException extends \LogicException { +} diff --git a/core/lib/Drupal/Core/Update/UpdateRegistry.php b/core/lib/Drupal/Core/Update/UpdateRegistry.php index 76ac81cb8ad7..8b3f03ea24ef 100644 --- a/core/lib/Drupal/Core/Update/UpdateRegistry.php +++ b/core/lib/Drupal/Core/Update/UpdateRegistry.php @@ -86,6 +86,21 @@ public function __construct($root, $site_path, array $enabled_modules, KeyValueS $this->includeTests = $include_tests; } + /** + * Gets removed hook_post_update_NAME() implementations for a module. + * + * @return string[] + * A list of post-update functions that have been removed. + */ + public function getRemovedPostUpdates($module) { + $this->scanExtensionsAndLoadUpdateFiles(); + $function = "{$module}_removed_post_updates"; + if (function_exists($function)) { + return $function(); + } + return []; + } + /** * Gets all available update functions. * @@ -102,11 +117,17 @@ protected function getAvailableUpdateFunctions() { // module updates. if (preg_match($regexp, $function, $matches)) { if (in_array($matches['module'], $this->enabledModules)) { - $updates[] = $matches['module'] . '_' . $this->updateType . '_' . $matches['name']; + $function_name = $matches['module'] . '_' . $this->updateType . '_' . $matches['name']; + if ($this->updateType === 'post_update') { + $removed = array_keys($this->getRemovedPostUpdates($matches['module'])); + if (array_search($function_name, $removed) !== FALSE) { + throw new RemovedPostUpdateNameException(sprintf('The following update is specified as removed in hook_removed_post_updates() but still exists in the code base: %s', $function_name)); + } + } + $updates[] = $function_name; } } } - // Ensure that the update order is deterministic. sort($updates); return $updates; diff --git a/core/modules/system/system.install b/core/modules/system/system.install index 140de3b1aecb..8db3d323679e 100644 --- a/core/modules/system/system.install +++ b/core/modules/system/system.install @@ -19,6 +19,7 @@ use Drupal\Core\Site\Settings; use Drupal\Core\StreamWrapper\PrivateStream; use Drupal\Core\StreamWrapper\PublicStream; +use Drupal\Core\StringTranslation\PluralTranslatableMarkup; use Drupal\Core\StringTranslation\TranslatableMarkup; use Drupal\Core\Url; use Symfony\Component\HttpFoundation\Request; @@ -1135,6 +1136,35 @@ function system_requirements($phase) { ]; } } + // Also check post-updates. Only do this if we're not already showing an + // error for hook_update_N(). + if (empty($module_list)) { + $existing_updates = \Drupal::service('keyvalue')->get('post_update')->get('existing_updates', []); + $post_update_registry = \Drupal::service('update.post_update_registry'); + $modules = \Drupal::moduleHandler()->getModuleList(); + $module_extension_list = \Drupal::service('extension.list.module'); + foreach ($modules as $module => $extension) { + $module_info = $module_extension_list->get($module); + $removed_post_updates = $post_update_registry->getRemovedPostUpdates($module); + if ($missing_updates = array_diff(array_keys($removed_post_updates), $existing_updates)) { + $versions = array_unique(array_intersect_key($removed_post_updates, array_flip($missing_updates))); + $description = new PluralTranslatableMarkup(count($versions), + 'The installed version of the %module module is too old to update. Update to a version prior to @versions first (missing updates: @missing_updates).', + 'The installed version of the %module module is too old to update. Update first to a version prior to all of the following: @versions (missing updates: @missing_updates).', + [ + '%module' => $module_info->info['name'], + '@missing_updates' => implode(', ', $missing_updates), + '@versions' => implode(', ', $versions), + ] + ); + $requirements[$module . '_post_update_removed'] = [ + 'title' => t('Missing updates for: @module', ['@module' => $module_info->info['name']]), + 'description' => $description, + 'severity' => REQUIREMENT_ERROR, + ]; + } + } + } } return $requirements; diff --git a/core/modules/system/tests/modules/update_test_postupdate/update_test_postupdate.post_update.php b/core/modules/system/tests/modules/update_test_postupdate/update_test_postupdate.post_update.php index 54e1ddf61891..db3cf193e497 100644 --- a/core/modules/system/tests/modules/update_test_postupdate/update_test_postupdate.post_update.php +++ b/core/modules/system/tests/modules/update_test_postupdate/update_test_postupdate.post_update.php @@ -67,3 +67,15 @@ function update_test_postupdate_post_update_test_batch(&$sandbox = NULL) { $sandbox['#finished'] = $sandbox['current_step'] / $sandbox['steps']; return 'Test post update batches'; } + +/** + * Implements hook_removed_post_updates(). + */ +function update_test_postupdate_removed_post_updates() { + return [ + 'update_test_postupdate_post_update_foo' => '8.x-1.0', + 'update_test_postupdate_post_update_bar' => '8.x-2.0', + 'update_test_postupdate_post_update_pub' => '3.0.0', + 'update_test_postupdate_post_update_baz' => '3.0.0', + ]; +} diff --git a/core/modules/system/tests/src/Functional/Module/InstallUninstallTest.php b/core/modules/system/tests/src/Functional/Module/InstallUninstallTest.php index 6e878b03982d..355612099e94 100644 --- a/core/modules/system/tests/src/Functional/Module/InstallUninstallTest.php +++ b/core/modules/system/tests/src/Functional/Module/InstallUninstallTest.php @@ -300,7 +300,16 @@ protected function assertInstallModuleUpdates($module) { $existing_updates = \Drupal::keyValue('post_update')->get('existing_updates', []); switch ($module) { case 'update_test_postupdate': - $this->assertEmpty(array_diff(['update_test_postupdate_post_update_first', 'update_test_postupdate_post_update_second', 'update_test_postupdate_post_update_test1', 'update_test_postupdate_post_update_test0'], $existing_updates)); + $expected = [ + 'update_test_postupdate_post_update_first', + 'update_test_postupdate_post_update_second', + 'update_test_postupdate_post_update_test1', + 'update_test_postupdate_post_update_test0', + 'update_test_postupdate_post_update_foo', + 'update_test_postupdate_post_update_bar', + 'update_test_postupdate_post_update_baz', + ]; + $this->assertSame($expected, $existing_updates); break; } } diff --git a/core/modules/system/tests/src/Functional/UpdateSystem/UpdatePostUpdateTest.php b/core/modules/system/tests/src/Functional/UpdateSystem/UpdatePostUpdateTest.php index 46f5ea37c87b..d7502ba05824 100644 --- a/core/modules/system/tests/src/Functional/UpdateSystem/UpdatePostUpdateTest.php +++ b/core/modules/system/tests/src/Functional/UpdateSystem/UpdatePostUpdateTest.php @@ -54,6 +54,19 @@ protected function setUp() { ->condition('collection', '') ->condition('name', 'core.extension') ->execute(); + + // Mimic the behaviour of ModuleInstaller::install() for removed post + // updates. Don't include the actual post updates because we want them to + // run. + $key_value = \Drupal::service('keyvalue'); + $existing_updates = $key_value->get('post_update')->get('existing_updates', []); + $post_updates = [ + 'update_test_postupdate_post_update_foo', + 'update_test_postupdate_post_update_bar', + 'update_test_postupdate_post_update_pub', + 'update_test_postupdate_post_update_baz', + ]; + $key_value->get('post_update')->set('existing_updates', array_merge($existing_updates, $post_updates)); } /** diff --git a/core/modules/system/tests/src/Functional/UpdateSystem/UpdateRemovedPostUpdateTest.php b/core/modules/system/tests/src/Functional/UpdateSystem/UpdateRemovedPostUpdateTest.php new file mode 100644 index 000000000000..096b6473c85b --- /dev/null +++ b/core/modules/system/tests/src/Functional/UpdateSystem/UpdateRemovedPostUpdateTest.php @@ -0,0 +1,133 @@ +<?php + +namespace Drupal\Tests\system\Functional\UpdateSystem; + +use Drupal\Core\Database\Database; +use Drupal\Core\Url; +use Drupal\Tests\BrowserTestBase; +use Drupal\Tests\UpdatePathTestTrait; + +/** + * Tests hook_removed_post_updates(). + * + * @group Update + */ +class UpdateRemovedPostUpdateTest extends BrowserTestBase { + use UpdatePathTestTrait; + + /** + * {@inheritdoc} + */ + protected $defaultTheme = 'stark'; + + /** + * {@inheritdoc} + */ + protected function setUp() { + parent::setUp(); + $connection = Database::getConnection(); + + // Set the schema version. + $connection->merge('key_value') + ->condition('collection', 'system.schema') + ->condition('name', 'update_test_postupdate') + ->fields([ + 'collection' => 'system.schema', + 'name' => 'update_test_postupdate', + 'value' => 'i:8000;', + ]) + ->execute(); + + // Update core.extension. + $extensions = $connection->select('config') + ->fields('config', ['data']) + ->condition('collection', '') + ->condition('name', 'core.extension') + ->execute() + ->fetchField(); + $extensions = unserialize($extensions); + $extensions['module']['update_test_postupdate'] = 8000; + $connection->update('config') + ->fields([ + 'data' => serialize($extensions), + ]) + ->condition('collection', '') + ->condition('name', 'core.extension') + ->execute(); + + $this->updateUrl = Url::fromRoute('system.db_update'); + $this->updateUser = $this->drupalCreateUser(['administer software updates']); + } + + /** + * Tests hook_post_update_NAME(). + */ + public function testRemovedPostUpdate() { + // Mimic the behaviour of ModuleInstaller::install(). + $key_value = \Drupal::service('keyvalue'); + $existing_updates = $key_value->get('post_update')->get('existing_updates', []); + + // Excludes 'update_test_postupdate_post_update_baz', + // 'update_test_postupdate_post_update_bar', and + // 'update_test_postupdate_pub' to simulate a module updating from + // a version prior to the post-updates being added, to a version + // after they were removed. + $post_updates = [ + 'update_test_postupdate_post_update_first', + 'update_test_postupdate_post_update_second', + 'update_test_postupdate_post_update_test1', + 'update_test_postupdate_post_update_test0', + 'update_test_postupdate_post_update_foo', + ]; + $key_value->get('post_update')->set('existing_updates', array_merge($existing_updates, $post_updates)); + + // The message should inform us we've skipped two major versions. + $this->drupalLogin($this->updateUser); + $this->drupalGet($this->updateUrl); + $assert_session = $this->assertSession(); + $assert_session->pageTextContains('Requirements problem'); + $assert_session->pageTextContains('The installed version of the Update test after module is too old to update. Update first to a version prior to all of the following: 8.x-2.0, 3.0.0'); + $assert_session->pageTextContains('update_test_postupdate_post_update_baz'); + $assert_session->pageTextContains('update_test_postupdate_post_update_bar'); + $assert_session->pageTextContains('update_test_postupdate_post_update_pub'); + + // Excludes 'update_test_postupdate_post_update_baz' and + // 'update_test_post_update_pub' to simulate two updates being + // removed from a single version. + $post_updates = [ + 'update_test_postupdate_post_update_first', + 'update_test_postupdate_post_update_second', + 'update_test_postupdate_post_update_test1', + 'update_test_postupdate_post_update_test0', + 'update_test_postupdate_post_update_foo', + 'update_test_postupdate_post_update_bar', + ]; + $key_value->get('post_update')->set('existing_updates', array_merge($existing_updates, $post_updates)); + // Now the message should inform us we've skipped one version. + $this->drupalGet($this->updateUrl); + $assert_session = $this->assertSession(); + $assert_session->pageTextContains('Requirements problem'); + $assert_session->pageTextContains('The installed version of the Update test after module is too old to update. Update to a version prior to 3.0.0'); + $assert_session->pageTextContains('update_test_postupdate_post_update_baz'); + $assert_session->pageTextContains('update_test_postupdate_post_update_pub'); + + // Excludes 'update_test_postupdate_post_update_baz' to simulate + // updating when only a single update has been skipped. + $post_updates = [ + 'update_test_postupdate_post_update_first', + 'update_test_postupdate_post_update_second', + 'update_test_postupdate_post_update_test1', + 'update_test_postupdate_post_update_test0', + 'update_test_postupdate_post_update_foo', + 'update_test_postupdate_post_update_bar', + 'update_test_postupdate_post_update_pub', + ]; + $key_value->get('post_update')->set('existing_updates', array_merge($existing_updates, $post_updates)); + $this->drupalGet($this->updateUrl); + $assert_session = $this->assertSession(); + $assert_session->pageTextContains('Requirements problem'); + $assert_session->pageTextContains('The installed version of the Update test after module is too old to update. Update to a version prior to 3.0.0'); + $assert_session->pageTextContains('update_test_postupdate_post_update_baz'); + } + +} diff --git a/core/tests/Drupal/Tests/Core/Update/UpdateRegistryTest.php b/core/tests/Drupal/Tests/Core/Update/UpdateRegistryTest.php index 64a6bad790a3..9527de870e3c 100644 --- a/core/tests/Drupal/Tests/Core/Update/UpdateRegistryTest.php +++ b/core/tests/Drupal/Tests/Core/Update/UpdateRegistryTest.php @@ -4,6 +4,7 @@ use Drupal\Core\KeyValueStore\KeyValueStoreInterface; use Drupal\Core\Site\Settings; +use Drupal\Core\Update\RemovedPostUpdateNameException; use Drupal\Core\Update\UpdateRegistry; use Drupal\Tests\UnitTestCase; use org\bovigo\vfs\vfsStream; @@ -44,6 +45,12 @@ protected function setupBasicModules() { type: module name: Module B core_version_requirement: '*' +EOS; + + $info_c = <<<'EOS' +type: module +name: Module C +core_version_requirement: '*' EOS; $module_a = <<<'EOS' @@ -71,6 +78,43 @@ function module_a_post_update_a() { function module_b_post_update_a() { } +/** + * Implements hook_removed_post_updates(). + */ +function module_b_removed_post_updates() { + return [ + 'module_b_post_update_b' => '8.9.0', + 'module_b_post_update_c' => '8.9.0', + ]; +} + +EOS; + + $module_c = <<<'EOS' +<?php + +/** + * Module C update A. + */ +function module_c_post_update_a() { +} + +/** + * Module C update B. + */ +function module_c_post_update_b() { +} + +/** + * Implements hook_removed_post_updates(). + */ +function module_c_removed_post_updates() { + return [ + 'module_c_post_update_b' => '8.9.0', + 'module_c_post_update_c' => '8.9.0', + ]; +} + EOS; vfsStream::setup('drupal'); vfsStream::create([ @@ -85,6 +129,10 @@ function module_b_post_update_a() { 'module_b.post_update.php' => $module_b, 'module_b.info.yml' => $info_b, ], + 'module_c' => [ + 'module_c.post_update.php' => $module_c, + 'module_c.info.yml' => $info_c, + ], ], ], ], @@ -209,6 +257,24 @@ public function testGetPendingUpdateInformationWithExistingUpdates() { $this->assertEquals($expected, $update_registry->getPendingUpdateInformation()); } + /** + * @covers ::getPendingUpdateInformation + */ + public function testGetPendingUpdateInformationWithRemovedUpdates() { + $this->setupBasicModules(); + + $key_value = $this->prophesize(KeyValueStoreInterface::class); + $key_value->get('existing_updates', [])->willReturn(['module_a_post_update_a']); + $key_value = $key_value->reveal(); + + $update_registry = new UpdateRegistry('vfs://drupal', 'sites/default', [ + 'module_c', + ], $key_value, FALSE); + + $this->expectException(RemovedPostUpdateNameException::class); + $update_registry->getPendingUpdateInformation(); + } + /** * @covers ::getModuleUpdateFunctions */ -- GitLab