Commit 7d16f1c6 authored by catch's avatar catch

Issue #2538108 by dawehner, jhedstrom: Add hook_post_update_X() for data value...

Issue #2538108 by dawehner, jhedstrom: Add hook_post_update_X() for data value updates to reliably run after data format updates
parent 56993d41
......@@ -1518,6 +1518,12 @@ services:
- { name: placeholder_strategy, priority: -1000 }
email.validator:
class: Egulias\EmailValidator\EmailValidator
update.post_update_registry:
class: Drupal\Core\Update\UpdateRegistry
factory: ['@update.post_update_registry_factory', create]
update.post_update_registry_factory:
class: Drupal\Core\Update\UpdateRegistryFactory
parent: container.trait
response_filter.active_link:
class: Drupal\Core\EventSubscriber\ActiveLinkResponseFilter
......
......@@ -182,6 +182,7 @@ function update_do_one($module, $number, $dependency_map, &$context) {
// @TODO We may want to do different error handling for different
// exception types, but for now we'll just log the exception and
// return the message for printing.
// @see https://www.drupal.org/node/2564311
catch (Exception $e) {
watchdog_exception('update', $e);
......@@ -214,7 +215,66 @@ function update_do_one($module, $number, $dependency_map, &$context) {
drupal_set_installed_schema_version($module, $number);
}
$context['message'] = 'Updating ' . Html::escape($module) . ' module';
$context['message'] = t('Updating @module', ['@module' => $module]);
}
/**
* Executes a single hook_post_update_NAME().
*
* @param string $function
* The function name, that should be executed.
* @param array $context
* The batch context array.
*/
function update_invoke_post_update($function, &$context) {
$ret = [];
// If this update was aborted in a previous step, or has a dependency that was
// aborted in a previous step, go no further.
if (!empty($context['results']['#abort'])) {
return;
}
list($module, $name) = explode('_post_update_', $function, 2);
module_load_include('php', $module, $module . '.post_update');
if (function_exists($function)) {
try {
$ret['results']['query'] = $function($context['sandbox']);
$ret['results']['success'] = TRUE;
\Drupal::service('update.post_update_registry')->registerInvokedUpdates([$function]);
}
// @TODO We may want to do different error handling for different exception
// types, but for now we'll just log the exception and return the message
// for printing.
// @see https://www.drupal.org/node/2564311
catch (Exception $e) {
watchdog_exception('update', $e);
$variables = Error::decodeException($e);
unset($variables['backtrace']);
$ret['#abort'] = [
'success' => FALSE,
'query' => t('%type: @message in %function (line %line of %file).', $variables),
];
}
}
if (isset($context['sandbox']['#finished'])) {
$context['finished'] = $context['sandbox']['#finished'];
unset($context['sandbox']['#finished']);
}
if (!isset($context['results'][$module][$name])) {
$context['results'][$module][$name] = array();
}
$context['results'][$module][$name] = array_merge($context['results'][$module][$name], $ret);
if (!empty($ret['#abort'])) {
// Record this function in the list of updates that were aborted.
$context['results']['#abort'][] = $function;
}
$context['message'] = t('Post updating @module', ['@module' => $module]);
}
/**
......
......@@ -90,15 +90,30 @@ class ExtensionDiscovery {
*/
protected $fileCache;
/**
* The site path.
*
* @var string
*/
protected $sitePath;
/**
* Constructs a new ExtensionDiscovery object.
*
* @param string $root
* The app root.
* @param bool $use_file_cache
* Whether file cache should be used.
* @param string[] $profile_directories
* The available profile directories
* @param string $site_path
* The path to the site.
*/
public function __construct($root) {
public function __construct($root, $use_file_cache = TRUE, $profile_directories = NULL, $site_path = NULL) {
$this->root = $root;
$this->fileCache = FileCacheFactory::get('extension_discovery');
$this->fileCache = $use_file_cache ? FileCacheFactory::get('extension_discovery') : NULL;
$this->profileDirectories = $profile_directories;
$this->sitePath = $site_path;
}
/**
......@@ -172,7 +187,7 @@ public function scan($type, $include_tests = NULL) {
$searchdirs[static::ORIGIN_SITE] = \Drupal::service('site.path');
}
else {
$searchdirs[static::ORIGIN_SITE] = DrupalKernel::findSitePath(Request::createFromGlobals());
$searchdirs[static::ORIGIN_SITE] = $this->sitePath ?: DrupalKernel::findSitePath(Request::createFromGlobals());
}
// Unless an explicit value has been passed, manually check whether we are
......@@ -180,7 +195,7 @@ public function scan($type, $include_tests = NULL) {
// Test extensions can also be included for debugging purposes by setting a
// variable in settings.php.
if (!isset($include_tests)) {
$include_tests = drupal_valid_test_ua() || Settings::get('extension_discovery_scan_tests');
$include_tests = Settings::get('extension_discovery_scan_tests') || drupal_valid_test_ua();
}
$files = array();
......@@ -427,7 +442,7 @@ protected function scanDirectory($dir, $include_tests) {
continue;
}
if ($cached_extension = $this->fileCache->get($fileinfo->getPathName())) {
if ($this->fileCache && $cached_extension = $this->fileCache->get($fileinfo->getPathName())) {
$files[$cached_extension->getType()][$key] = $cached_extension;
continue;
}
......@@ -467,7 +482,10 @@ protected function scanDirectory($dir, $include_tests) {
$extension->origin = $dir;
$files[$type][$key] = $extension;
$this->fileCache->set($fileinfo->getPathName(), $extension);
if ($this->fileCache) {
$this->fileCache->set($fileinfo->getPathName(), $extension);
}
}
return $files;
}
......
......@@ -260,6 +260,11 @@ 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.
/** @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));
// Record the fact that it was installed.
$modules_installed[] = $module;
......@@ -445,6 +450,10 @@ public function uninstall(array $module_list, $uninstall_dependents = TRUE) {
$schema_store = \Drupal::keyValue('system.schema');
$schema_store->delete($module);
/** @var \Drupal\Core\Update\UpdateRegistry $post_update_registry */
$post_update_registry = \Drupal::service('update.post_update_registry');
$post_update_registry->filterOutInvokedUpdatesByModule($module);
}
\Drupal::service('router.builder')->setRebuildNeeded();
drupal_get_installed_schema_version(NULL, TRUE);
......
......@@ -659,6 +659,71 @@ function hook_update_N(&$sandbox) {
return t('All foo bars were updated with the new suffix');
}
/**
* Executes an update which is intended to update data, like entities.
*
* These implementations have to be placed in a MODULE.post_update.php file.
*
* These updates are executed after all hook_update_N() implementations. At this
* stage Drupal is already fully repaired so you can use any API as you wish.
*
* NAME can be arbitrary machine names. In contrast to hook_update_N() the order
* of functions in the file is the only thing which ensures the execution order
* of those functions.
*
* Drupal also ensures to not execute the same hook_post_update_NAME() function
* twice.
*
* @param array $sandbox
* Stores information for batch updates. See above for more information.
*
* @throws \Drupal\Core\Utility\UpdateException|PDOException
* In case of error, update hooks should throw an instance of
* \Drupal\Core\Utility\UpdateException with a meaningful message for the
* user. If a database query fails for whatever reason, it will throw a
* PDOException.
*
* @return string|null
* Optionally, hook_post_update_NAME() hooks may return a translated string
* that will be displayed to the user after the update has completed. If no
* message is returned, no message will be presented to the user.
*
* @ingroup update_api
*
* @see hook_update_N()
*/
function hook_post_update_NAME(&$sandbox) {
// Example of updating some content.
$node = \Drupal\node\Entity\Node::load(123);
$node->setTitle('foo');
$node->save();
$result = t('Node %nid saved', ['%nid' => $node->id()]);
// Example of disabling blocks with missing condition contexts. Note: The
// block itself is in a state which is valid at that point.
// @see block_update_8001()
// @see block_post_update_disable_blocks_with_missing_contexts()
$block_update_8001 = \Drupal::keyValue('update_backup')->get('block_update_8001', []);
$block_ids = array_keys($block_update_8001);
$block_storage = \Drupal::entityManager()->getStorage('block');
$blocks = $block_storage->loadMultiple($block_ids);
/** @var $blocks \Drupal\block\BlockInterface[] */
foreach ($blocks as $block) {
// This block has had conditions removed due to an inability to resolve
// contexts in block_update_8001() so disable it.
// Disable currently enabled blocks.
if ($block_update_8001[$block->id()]['status']) {
$block->setStatus(FALSE);
$block->save();
}
}
return $result;
}
/**
* Return an array of information about module update dependencies.
*
......
<?php
/**
* @file
* Contains \Drupal\Core\Update\UpdateRegistry.
*/
namespace Drupal\Core\Update;
use Drupal\Core\Extension\Extension;
use Drupal\Core\Extension\ExtensionDiscovery;
use Drupal\Core\KeyValueStore\KeyValueStoreInterface;
/**
* Provides all and missing update implementations.
*
* Note: This registry is specific to a type of updates, like 'post_update' as
* example.
*
* It therefore scans for functions named like the type of updates, so it looks
* like MODULE_UPDATETYPE_NAME() with NAME being a machine name.
*/
class UpdateRegistry {
/**
* The used update name.
*
* @var string
*/
protected $updateType = 'post_update';
/**
* The app root.
*
* @var string
*/
protected $root;
/**
* The filename of the log file.
*
* @var string
*/
protected $logFilename;
/**
* @var string[]
*/
protected $enabledModules;
/**
* The key value storage.
*
* @var \Drupal\Core\KeyValueStore\KeyValueStoreInterface
*/
protected $keyValue;
/**
* Should we respect update functions in tests.
*
* @var bool|null
*/
protected $includeTests = NULL;
/**
* The site path.
*
* @var string
*/
protected $sitePath;
/**
* Constructs a new UpdateRegistry.
*
* @param string $root
* The app root.
* @param string $site_path
* The site path.
* @param string[] $enabled_modules
* A list of enabled modules.
* @param \Drupal\Core\KeyValueStore\KeyValueStoreInterface $key_value
* The key value store.
* @param bool|NULL $include_tests
* (optional) A flag whether to include tests in the scanning of modules.
*/
public function __construct($root, $site_path, array $enabled_modules, KeyValueStoreInterface $key_value, $include_tests = NULL) {
$this->root = $root;
$this->sitePath = $site_path;
$this->enabledModules = $enabled_modules;
$this->keyValue = $key_value;
$this->includeTests = $include_tests;
}
/**
* Gets all available update functions.
*
* @return callable[]
* A list of update functions.
*/
protected function getAvailableUpdateFunctions() {
$regexp = '/^(?<module>.+)_' . $this->updateType . '_(?<name>.+)$/';
$functions = get_defined_functions();
$updates = [];
foreach (preg_grep('/_' . $this->updateType . '_/', $functions['user']) as $function) {
// If this function is a module update function, add it to the list of
// module updates.
if (preg_match($regexp, $function, $matches)) {
if (in_array($matches['module'], $this->enabledModules)) {
$updates[] = $matches['module'] . '_' . $this->updateType . '_' . $matches['name'];
}
}
}
return $updates;
}
/**
* Find all update functions that haven't been executed.
*
* @return callable[]
* A list of update functions.
*/
public function getPendingUpdateFunctions() {
// We need a) the list of active modules (we get that from the config
// bootstrap factory) and b) the path to the modules, we use the extension
// discovery for that.
$this->scanExtensionsAndLoadUpdateFiles();
// First figure out which hook_{$this->updateType}_NAME got executed
// already.
$existing_update_functions = $this->keyValue->get('existing_updates', []);
$available_update_functions = $this->getAvailableUpdateFunctions();
$not_executed_update_functions = array_diff($available_update_functions, $existing_update_functions);
return $not_executed_update_functions;
}
/**
* Loads all update files for a given list of extension.
*
* @param \Drupal\Core\Extension\Extension[] $module_extensions
* The extensions used for loading.
*/
protected function loadUpdateFiles(array $module_extensions) {
// Load all the {$this->updateType}.php files.
foreach ($this->enabledModules as $module) {
if (isset($module_extensions[$module])) {
$this->loadUpdateFile($module_extensions[$module]);
}
}
}
/**
* Loads the {$this->updateType}.php file for a given extension.
*
* @param \Drupal\Core\Extension\Extension $module
* The extension of the module to load its file.
*/
protected function loadUpdateFile(Extension $module) {
$filename = $this->root . '/' . $module->getPath() . '/' . $module->getName() . ".{$this->updateType}.php";
if (file_exists($filename)) {
include_once $filename;
}
}
/**
* Returns a list of all the pending updates.
*
* @return array[]
* An associative array keyed by module name which contains all information
* about database updates that need to be run, and any updates that are not
* going to proceed due to missing requirements.
*
* The subarray for each module can contain the following keys:
* - start: The starting update that is to be processed. If this does not
* exist then do not process any updates for this module as there are
* other requirements that need to be resolved.
* - pending: An array of all the pending updates for the module including
* the description from source code comment for each update function.
* This array is keyed by the update name.
*/
public function getPendingUpdateInformation() {
$functions = $this->getPendingUpdateFunctions();
$ret = [];
foreach ($functions as $function) {
list($module, $update) = explode("_{$this->updateType}_", $function);
// The description for an update comes from its Doxygen.
$func = new \ReflectionFunction($function);
$description = trim(str_replace(array("\n", '*', '/'), '', $func->getDocComment()), ' ');
$ret[$module]['pending'][$update] = $description;
if (!isset($ret[$module]['start'])) {
$ret[$module]['start'] = $update;
}
}
return $ret;
}
/**
* Registers that update fucntions got executed.
*
* @param string[] $function_names
* The executed update functions.
*
* @return $this
*/
public function registerInvokedUpdates(array $function_names) {
$executed_updates = $this->keyValue->get('existing_updates', []);
$executed_updates = array_merge($executed_updates, $function_names);
$this->keyValue->set('existing_updates', $executed_updates);
return $this;
}
/**
* Returns all available updates for a given module.
*
* @param string $module_name
* The module name.
*
* @return callable[]
* A list of update functions.
*/
public function getModuleUpdateFunctions($module_name) {
$this->scanExtensionsAndLoadUpdateFiles();
$all_functions = $this->getAvailableUpdateFunctions();
return array_filter($all_functions, function($function_name) use ($module_name) {
list($function_module_name, ) = explode("_{$this->updateType}_", $function_name);
return $function_module_name === $module_name;
});
}
/**
* Scans all module + profile extensions and load the update files.
*/
protected function scanExtensionsAndLoadUpdateFiles() {
// Scan the module list.
$extension_discovery = new ExtensionDiscovery($this->root, FALSE, [], $this->sitePath);
$module_extensions = $extension_discovery->scan('module');
$profile_extensions = $extension_discovery->scan('profile');
$extensions = array_merge($module_extensions, $profile_extensions);
$this->loadUpdateFiles($extensions);
}
/**
* Filters out already executed update functions by module.
*
* @param string $module
* The module name.
*/
public function filterOutInvokedUpdatesByModule($module) {
$existing_update_functions = $this->keyValue->get('existing_updates', []);
$remaining_update_functions = array_filter($existing_update_functions, function($function_name) use ($module) {
return strpos($function_name, "{$module}_{$this->updateType}_") !== 0;
});
$this->keyValue->set('existing_updates', array_values($remaining_update_functions));
}
}
<?php
/**
* @file
* Contains \Drupal\Core\Update\UpdateRegistryFactory.
*/
namespace Drupal\Core\Update;
use Symfony\Component\DependencyInjection\ContainerAwareInterface;
use Symfony\Component\DependencyInjection\ContainerAwareTrait;
/**
* Service factory for the update registry.
*/
class UpdateRegistryFactory implements ContainerAwareInterface {
use ContainerAwareTrait;
/**
* Creates a new UpdateRegistry instance.
*
* @return \Drupal\Core\Update\UpdateRegistry
* The update registry instance.
*/
public function create() {
return new UpdateRegistry($this->container->get('app.root'), $this->container->get('site.path'), array_keys($this->container->get('module_handler')->getModuleList()), $this->container->get('keyvalue')->get('post_update'));
}
}
......@@ -45,8 +45,9 @@ function block_update_8001() {
}
// Contributed modules should leverage hook_update_dependencies() in order to
// be executed before block_update_8002(), so they can update their context
// mappings, if wanted.
// be executed after block_update_8001(). The blocks are then disabled if the
// contexts are still missing via
// block_post_update_disable_blocks_with_missing_contexts().
$config_factory = \Drupal::configFactory();
$backup_values = $update_backup = [];
......@@ -95,50 +96,10 @@ function block_update_8001() {
}
/**
* Disable all blocks with missing context IDs in block_update_8001().
* Placeholder for the previous 8002 update.
*/
function block_update_8002() {
$block_update_8001 = \Drupal::keyValue('update_backup')->get('block_update_8001', []);
$block_ids = array_keys($block_update_8001);
$config_factory = \Drupal::configFactory();
/** @var \Drupal\Core\Config\Config[] $blocks */
$blocks = [];
foreach ($block_ids as $block_id) {
$blocks[$block_id] = $block = $config_factory->getEditable('block.block.' . $block_id);
// This block will have an invalid context mapping service and must be
// disabled in order to prevent information disclosure.
// Disable currently enabled blocks.
if ($block_update_8001[$block_id]['status']) {
$block->set('status', FALSE);
$block->save(TRUE);
}
}
// Provides a list of plugin labels, keyed by plugin ID.
$condition_plugin_id_label_map = array_column(\Drupal::service('plugin.manager.condition')->getDefinitions(), 'label', 'id');
// Override with the UI labels we are aware of. Sadly they are not machine
// accessible, see
// \Drupal\node\Plugin\Condition\NodeType::buildConfigurationForm().
$condition_plugin_id_label_map['node_type'] = t('Content types');
$condition_plugin_id_label_map['request_path'] = t('Pages');
$condition_plugin_id_label_map['user_role'] = t('Roles');
if (count($block_ids) > 0) {
$message = t('Encountered an unknown context mapping key coming probably from a contributed or custom module: One or more mappings could not be updated. Please manually review your visibility settings for the following blocks, which are disabled now:');
$message .= '<ul>';
foreach ($blocks as $disabled_block_id => $disabled_block) {
$message .= '<li>' . t('@label (Visibility: @plugin_ids)', array(
'@label' => $disabled_block->get('settings.label'),
'@plugin_ids' => implode(', ', array_intersect_key($condition_plugin_id_label_map, array_flip(array_keys($block_update_8001[$disabled_block_id]['missing_context_ids']))))
)) . '</li>';
}
$message .= '</ul>';
return $message;
}
\Drupal::state()->set('block_update_8002_placeholder', TRUE);
}
/**
......
<?php
/**
* @file
* Post update functions for Block.
*/
/**
* @addtogroup updates-8.0.0-beta
* @{
*/
/**
* Disable all blocks with missing context IDs in block_update_8001().
*/
function block_post_update_disable_blocks_with_missing_contexts() {
// Don't execute the function if block_update_8002() got executed already,
// which used to do the same. Note: Its okay to check here, because
// update_do_one() does not update the installed schema version until the
// batch is finished.
$module_schema = drupal_get_installed_schema_version('block');
// The state entry 'block_update_8002_placeholder' is used in order to
// indicate that the placeholder block_update_8002() function has been
// executed, so this function needs to be executed as well. If the non
// placeholder version of block_update_8002() got executed already, the state
// won't be set and we skip this update.
if ($module_schema >= 8002 && !\Drupal::state()->get('block_update_8002_placeholder', FALSE)) {
return;
}
// Cleanup the state entry as its no longer needed.
\Drupal::state()->delete('block_update_8002');
$block_update_8001 = \Drupal::keyValue('update_backup')->get('block_update_8001', []);
$block_ids = array_keys($block_update_8001);
$block_storage = \Drupal::entityManager()->getStorage('block');
$blocks = $block_storage->loadMultiple($block_ids);
/** @var $blocks \Drupal\block\BlockInterface[] */
foreach ($blocks as $block) {
// This block has had conditions removed due to an inability to resolve
// contexts in block_update_8001() so disable it.
// Disable currently enabled blocks.
if ($block_update_8001[$block->id()]['status']) {
$block->setStatus(FALSE);
$block->save();
}
}
// Provides a list of plugin labels, keyed by plugin ID.
$condition_plugin_id_label_map = array_column(\Drupal::service('plugin.manager.condition')->getDefinitions(), 'label', 'id');
// Override with the UI labels we are aware of. Sadly they are not machine
// accessible, see
// \Drupal\node\Plugin\Condition\NodeType::buildConfigurationForm().
$condition_plugin_id_label_map['node_type'] = t('Content types');
$condition_plugin_id_label_map['request_path'] = t('Pages');
$condition_plugin_id_label_map['user_role'] = t('Roles');
if (count($block_ids) > 0) {
$message = t('Encountered an unknown context mapping key coming probably from a contributed or custom module: One or more mappings could not be updated. Please manually review your visibility settings for the following blocks, which are disabled now:');
$message .= '<ul>';
foreach ($blocks as $disabled_block_id => $disabled_block) {
$message .= '<li>' . t('@label (Visibility: @plugin_ids)', array(
'@label' => $disabled_block->get('settings')['label'],
'@plugin_ids' => implode(', ', array_intersect_key($condition_plugin_id_label_map, array_flip(array_keys($block_update_8001[$disabled_block_id]['missing_context_ids']))))
)) . '</li>';
}
$message .= '</ul>';
return $message;
}
}
/**
* @} End of "addtogroup updates-8.0.0-beta".
*/
......@@ -15,6 +15,7 @@
use Drupal\Core\Session\AccountInterface;
use Drupal\Core\Site\Settings;
use Drupal\Core\State\StateInterface;
use Drupal\Core\Update\UpdateRegistry;
use Drupal\Core\Url;
use Symfony\Component\DependencyInjection\ContainerInterface;
use Symfony\Component\HttpFoundation\Response;
......@@ -74,6 +75,13 @@ class DbUpdateController extends ControllerBase {
*/
protected $root;
/**
* The post update registry.
*
* @var \Drupal\Core\Update\UpdateRegistry