Commit 5803f908 authored by catch's avatar catch

Issue #2392293 by bircher, tim.plunkett, alexpott, cilefen, gobinathm:...

Issue #2392293 by bircher, tim.plunkett, alexpott, cilefen, gobinathm: Refactor hook_system_info_alter implementations to use ModuleUninstallValidatorInterface
parent 9da500af
......@@ -418,6 +418,12 @@ services:
- { name: module_install.uninstall_validator }
arguments: ['@entity.manager', '@string_translation']
lazy: true
required_module_uninstall_validator:
class: Drupal\Core\Extension\RequiredModuleUninstallValidator
tags:
- { name: module_install.uninstall_validator }
arguments: ['@string_translation']
lazy: true
theme_handler:
class: Drupal\Core\Extension\ThemeHandler
arguments: ['@app.root', '@config.factory', '@module_handler', '@state', '@info_parser']
......
......@@ -17,6 +17,13 @@
class ContentUninstallValidator implements ModuleUninstallValidatorInterface {
use StringTranslationTrait;
/**
* The entity manager.
*
* @var \Drupal\Core\Entity\EntityManagerInterface
*/
protected $entityManager;
/**
* Constructs a new ContentUninstallValidator.
*
......
......@@ -334,7 +334,7 @@ public function uninstall(array $module_list, $uninstall_dependents = TRUE) {
$reason_message[] = implode(', ', $reason);
}
throw new ModuleUninstallValidatorException(format_string('The following reasons prevents the modules from being uninstalled: @reasons', array(
'@reasons' => implode(', ', $reason_message),
'@reasons' => implode('; ', $reason_message),
)));
}
// Set the actual module weights.
......
......@@ -20,6 +20,10 @@
*
* @return string[]
* An array of reasons the module can not be uninstalled, empty if it can.
* Each reason should not end with any punctuation since multiple reasons
* can be displayed together.
*
* @see theme_system_modules_uninstall()
*/
public function validate($module);
}
<?php
/**
* @file
* Contains \Drupal\Core\Extension\RequiredModuleUninstallValidator.
*/
namespace Drupal\Core\Extension;
use Drupal\Core\StringTranslation\StringTranslationTrait;
use Drupal\Core\StringTranslation\TranslationInterface;
/**
* Ensures that required modules cannot be uninstalled.
*/
class RequiredModuleUninstallValidator implements ModuleUninstallValidatorInterface {
use StringTranslationTrait;
/**
* Constructs a new RequiredModuleUninstallValidator.
*
* @param \Drupal\Core\StringTranslation\TranslationInterface $string_translation
* The string translation service.
*/
public function __construct(TranslationInterface $string_translation) {
$this->stringTranslation = $string_translation;
}
/**
* {@inheritdoc}
*/
public function validate($module) {
$reasons = [];
$module_info = $this->getModuleInfoByModule($module);
if (!empty($module_info['required'])) {
$reasons[] = $this->t('The @module module is required', ['@module' => $module_info['name']]);
}
return $reasons;
}
/**
* Returns the module info for a specific module.
*
* @param string $module
* The name of the module.
*
* @return array
* The module info, or NULL if that module does not exist.
*/
protected function getModuleInfoByModule($module) {
$modules = system_rebuild_module_data();
return isset($modules[$module]->info) ? $modules[$module]->info : [];
}
}
......@@ -53,7 +53,7 @@ public function validate($module_name) {
if ($storage_definition->getProvider() == $module_name) {
$storage = $this->entityManager->getStorage($entity_type_id);
if ($storage instanceof FieldableEntityStorageInterface && $storage->countFieldData($storage_definition, TRUE)) {
$reasons[] = $this->t('There is data for the field @field-name on entity type @entity_type.', array(
$reasons[] = $this->t('There is data for the field @field-name on entity type @entity_type', array(
'@field-name' => $storage_definition->getName(),
'@entity_type' => $entity_type->getLabel(),
));
......
......@@ -19,7 +19,6 @@
use Drupal\Core\Language\LanguageInterface;
use Drupal\Core\Entity\Display\EntityViewDisplayInterface;
use Drupal\Core\Template\Attribute;
use Drupal\Core\Extension\Extension;
/**
* Implements hook_help().
......@@ -536,33 +535,3 @@ function book_node_type_update(NodeTypeInterface $type) {
$config->save();
}
}
/**
* Implements hook_system_info_alter().
*
* Prevents book module from being uninstalled whilst any book nodes exist or
* there are any book outline stored.
*/
function book_system_info_alter(&$info, Extension $file, $type) {
// It is not safe use the entity query service during maintenance mode.
if ($type == 'module' && !defined('MAINTENANCE_MODE') && $file->getName() == 'book') {
if (\Drupal::service('book.outline_storage')->hasBooks()) {
$info['required'] = TRUE;
$info['explanation'] = t('To uninstall Book, delete all content that is part of a book.');
}
else {
// The book node type is provided by the Book module. Prevent uninstall if
// there are any nodes of that type.
$factory = \Drupal::service('entity.query');
$nodes = $factory->get('node')
->condition('type', 'book')
->accessCheck(FALSE)
->range(0, 1)
->execute();
if (!empty($nodes)) {
$info['required'] = TRUE;
$info['explanation'] = t('To uninstall Book, delete all content that has the Book content type.');
}
}
}
}
......@@ -28,3 +28,10 @@ services:
arguments: ['@request_stack']
tags:
- { name: cache.context}
book.uninstall_validator:
class: Drupal\book\BookUninstallValidator
tags:
- { name: module_install.uninstall_validator }
arguments: ['@book.outline_storage', '@entity.query', '@string_translation']
lazy: true
<?php
/**
* @file
* Contains \Drupal\book\BookUninstallValidator.
*/
namespace Drupal\book;
use Drupal\Core\Entity\Query\QueryFactory;
use Drupal\Core\Extension\ModuleUninstallValidatorInterface;
use Drupal\Core\StringTranslation\StringTranslationTrait;
use Drupal\Core\StringTranslation\TranslationInterface;
/**
* Prevents book module from being uninstalled whilst any book nodes exist or
* there are any book outline stored.
*/
class BookUninstallValidator implements ModuleUninstallValidatorInterface {
use StringTranslationTrait;
/**
* The book outline storage.
*
* @var \Drupal\book\BookOutlineStorageInterface
*/
protected $bookOutlineStorage;
/**
* The entity query for node.
*
* @var \Drupal\Core\Entity\Query\QueryInterface
*/
protected $entityQuery;
/**
* Constructs a new BookUninstallValidator.
*
* @param \Drupal\book\BookOutlineStorageInterface $book_outline_storage
* The book outline storage.
* @param \Drupal\Core\Entity\Query\QueryFactory $query_factory
* The entity query factory.
* @param \Drupal\Core\StringTranslation\TranslationInterface $string_translation
* The string translation service.
*/
public function __construct(BookOutlineStorageInterface $book_outline_storage, QueryFactory $query_factory, TranslationInterface $string_translation) {
$this->bookOutlineStorage = $book_outline_storage;
$this->entityQuery = $query_factory->get('node');
$this->stringTranslation = $string_translation;
}
/**
* {@inheritdoc}
*/
public function validate($module) {
$reasons = [];
if ($module == 'book') {
if ($this->hasBookOutlines()) {
$reasons[] = $this->t('To uninstall Book, delete all content that is part of a book');
}
else {
// The book node type is provided by the Book module. Prevent uninstall
// if there are any nodes of that type.
if ($this->hasBookNodes()) {
$reasons[] = $this->t('To uninstall Book, delete all content that has the Book content type');
}
}
}
return $reasons;
}
/**
* Checks if there are any books in an outline.
*
* @return bool
* TRUE if there are books, FALSE if not.
*/
protected function hasBookOutlines() {
return $this->bookOutlineStorage->hasBooks();
}
/**
* Determines if there is any book nodes or not.
*
* @return bool
* TRUE if there are book nodes, FALSE otherwise.
*/
protected function hasBookNodes() {
$nodes = $this->entityQuery
->condition('type', 'book')
->accessCheck(FALSE)
->range(0, 1)
->execute();
return !empty($nodes);
}
}
......@@ -44,8 +44,8 @@ protected function setUp() {
*/
public function testBookUninstall() {
// No nodes exist.
$module_data = _system_rebuild_module_data();
$this->assertFalse(isset($module_data['book']->info['required']), 'The book module is not required.');
$validation_reasons = \Drupal::service('module_installer')->validateUninstall(['book']);
$this->assertEqual([], $validation_reasons, 'The book module is not required.');
$content_type = NodeType::create(array(
'type' => $this->randomMachineName(),
......@@ -62,9 +62,8 @@ public function testBookUninstall() {
$node->save();
// One node in a book but not of type book.
$module_data = _system_rebuild_module_data();
$this->assertTrue($module_data['book']->info['required'], 'The book module is required.');
$this->assertEqual($module_data['book']->info['explanation'], t('To uninstall Book, delete all content that is part of a book.'));
$validation_reasons = \Drupal::service('module_installer')->validateUninstall(['book']);
$this->assertEqual(['To uninstall Book, delete all content that is part of a book'], $validation_reasons['book']);
$book_node = Node::create(array('type' => 'book'));
$book_node->book['bid'] = FALSE;
......@@ -72,15 +71,13 @@ public function testBookUninstall() {
// Two nodes, one in a book but not of type book and one book node (which is
// not in a book).
$module_data = _system_rebuild_module_data();
$this->assertTrue($module_data['book']->info['required'], 'The book module is required.');
$this->assertEqual($module_data['book']->info['explanation'], t('To uninstall Book, delete all content that is part of a book.'));
$validation_reasons = \Drupal::service('module_installer')->validateUninstall(['book']);
$this->assertEqual(['To uninstall Book, delete all content that is part of a book'], $validation_reasons['book']);
$node->delete();
// One node of type book but not actually part of a book.
$module_data = _system_rebuild_module_data();
$this->assertTrue($module_data['book']->info['required'], 'The book module is required.');
$this->assertEqual($module_data['book']->info['explanation'], t('To uninstall Book, delete all content that has the Book content type.'));
$validation_reasons = \Drupal::service('module_installer')->validateUninstall(['book']);
$this->assertEqual(['To uninstall Book, delete all content that has the Book content type'], $validation_reasons['book']);
$book_node->delete();
// No nodes exist therefore the book module is not required.
......@@ -91,8 +88,8 @@ public function testBookUninstall() {
$node->save();
// One node exists but is not part of a book therefore the book module is
// not required.
$module_data = _system_rebuild_module_data();
$this->assertFalse(isset($module_data['book']->info['required']), 'The book module is not required.');
$validation_reasons = \Drupal::service('module_installer')->validateUninstall(['book']);
$this->assertEqual([], $validation_reasons, 'The book module is not required.');
// Uninstall the Book module and check the node type is deleted.
\Drupal::service('module_installer')->uninstall(array('book'));
......
<?php
/**
* @file
* Contains \Drupal\Tests\book\Unit\BookUninstallValidatorTest.
*/
namespace Drupal\Tests\book\Unit;
use Drupal\Tests\UnitTestCase;
/**
* @coversDefaultClass \Drupal\book\BookUninstallValidator
* @group book
*/
class BookUninstallValidatorTest extends UnitTestCase {
/**
* @var \Drupal\book\BookUninstallValidator|\PHPUnit_Framework_MockObject_MockObject
*/
protected $bookUninstallValidator;
/**
* {@inheritdoc}
*/
protected function setUp() {
parent::setUp();
$this->bookUninstallValidator = $this->getMockBuilder('Drupal\book\BookUninstallValidator')
->disableOriginalConstructor()
->setMethods(['hasBookOutlines', 'hasBookNodes'])
->getMock();
$this->bookUninstallValidator->setStringTranslation($this->getStringTranslationStub());
}
/**
* @covers ::validate
*/
public function testValidateNotBook() {
$this->bookUninstallValidator->expects($this->never())
->method('hasBookOutlines');
$this->bookUninstallValidator->expects($this->never())
->method('hasBookNodes');
$module = 'not_book';
$expected = [];
$reasons = $this->bookUninstallValidator->validate($module);
$this->assertSame($expected, $reasons);
}
/**
* @covers ::validate
*/
public function testValidateEntityQueryWithoutResults() {
$this->bookUninstallValidator->expects($this->once())
->method('hasBookOutlines')
->willReturn(FALSE);
$this->bookUninstallValidator->expects($this->once())
->method('hasBookNodes')
->willReturn(FALSE);
$module = 'book';
$expected = [];
$reasons = $this->bookUninstallValidator->validate($module);
$this->assertSame($expected, $reasons);
}
/**
* @covers ::validate
*/
public function testValidateEntityQueryWithResults() {
$this->bookUninstallValidator->expects($this->once())
->method('hasBookOutlines')
->willReturn(FALSE);
$this->bookUninstallValidator->expects($this->once())
->method('hasBookNodes')
->willReturn(TRUE);
$module = 'book';
$expected = ['To uninstall Book, delete all content that has the Book content type'];
$reasons = $this->bookUninstallValidator->validate($module);
$this->assertSame($expected, $reasons);
}
/**
* @covers ::validate
*/
public function testValidateOutlineStorage() {
$this->bookUninstallValidator->expects($this->once())
->method('hasBookOutlines')
->willReturn(TRUE);
$this->bookUninstallValidator->expects($this->never())
->method('hasBookNodes');
$module = 'book';
$expected = ['To uninstall Book, delete all content that is part of a book'];
$reasons = $this->bookUninstallValidator->validate($module);
$this->assertSame($expected, $reasons);
}
}
......@@ -8,6 +8,7 @@
namespace Drupal\comment\Tests;
use Drupal\field\Entity\FieldStorageConfig;
use Drupal\Core\Extension\ModuleUninstallValidatorException;
use Drupal\simpletest\WebTestBase;
/**
......@@ -36,19 +37,23 @@ protected function setUp() {
}
/**
* Tests if comment module uninstallation properly deletes the field.
* Tests if comment module uninstallation fails if the field exists.
*
* @throws \Drupal\Core\Extension\ModuleUninstallValidatorException
*/
function testCommentUninstallWithField() {
// Ensure that the field exists before uninstallation.
$field_storage = FieldStorageConfig::loadByName('comment', 'comment_body');
$this->assertNotNull($field_storage, 'The comment_body field exists.');
// Uninstall the comment module which should trigger field deletion.
$this->container->get('module_installer')->uninstall(array('comment'));
// Check that the field is now deleted.
$field_storage = FieldStorageConfig::loadByName('comment', 'comment_body');
$this->assertNull($field_storage, 'The comment_body field has been deleted.');
// Uninstall the comment module which should trigger an exception.
try {
$this->container->get('module_installer')->uninstall(array('comment'));
$this->fail("Expected an exception when uninstall was attempted.");
}
catch (ModuleUninstallValidatorException $e) {
$this->pass("Caught an exception when uninstall was attempted.");
}
}
......@@ -65,6 +70,16 @@ function testCommentUninstallWithoutField() {
$field_storage = FieldStorageConfig::loadByName('comment', 'comment_body');
$this->assertNull($field_storage, 'The comment_body field has been deleted.');
// Manually delete the comment field on the node before module uninstallation.
$field_storage = FieldStorageConfig::loadByName('node', 'comment');
$this->assertNotNull($field_storage, 'The comment field exists.');
$field_storage->delete();
// Check that the field is now deleted.
$field_storage = FieldStorageConfig::loadByName('node', 'comment');
$this->assertNull($field_storage, 'The comment field has been deleted.');
field_purge_batch(10);
// Ensure that uninstallation succeeds even if the field has already been
// deleted manually beforehand.
$this->container->get('module_installer')->uninstall(array('comment'));
......
......@@ -8,8 +8,10 @@
namespace Drupal\config\Tests;
use Drupal\Core\Config\StorageComparer;
use Drupal\filter\Entity\FilterFormat;
use Drupal\system\Tests\Module\ModuleTestBase;
use Drupal\shortcut\Entity\Shortcut;
use Drupal\taxonomy\Entity\Term;
/**
* Tests the largest configuration import possible with all available modules.
......@@ -83,12 +85,13 @@ public function testInstallUninstall() {
// Purge the data.
field_purge_batch(1000);
// Delete any forum terms so it can be uninstalled.
$vid = $this->config('forum.settings')->get('vocabulary');
$terms = entity_load_multiple_by_properties('taxonomy_term', ['vid' => $vid]);
foreach ($terms as $term) {
$term->delete();
}
// Delete all terms.
$terms = Term::loadMultiple();
entity_delete_multiple('taxonomy_term', array_keys($terms));
// Delete all filter formats.
$filters = FilterFormat::loadMultiple();
entity_delete_multiple('filter_format', array_keys($filters));
// Delete any shortcuts so the shortcut module can be uninstalled.
$shortcuts = Shortcut::loadMultiple();
......@@ -97,7 +100,11 @@ public function testInstallUninstall() {
system_list_reset();
$all_modules = system_rebuild_module_data();
$modules_to_uninstall = array_filter($all_modules, function ($module) {
// Ensure that only core required modules and the install profile can not be uninstalled.
$validation_reasons = \Drupal::service('module_installer')->validateUninstall(array_keys($all_modules));
$this->assertEqual(['standard', 'system', 'user'], array_keys($validation_reasons));
$modules_to_uninstall = array_filter($all_modules, function ($module) use ($validation_reasons) {
// Filter required and not enabled modules.
if (!empty($module->info['required']) || $module->status == FALSE) {
return FALSE;
......@@ -109,6 +116,8 @@ public function testInstallUninstall() {
unset($modules_to_uninstall['config']);
$this->assertTrue(isset($modules_to_uninstall['comment']), 'The comment module will be disabled');
$this->assertTrue(isset($modules_to_uninstall['file']), 'The File module will be disabled');
$this->assertTrue(isset($modules_to_uninstall['editor']), 'The Editor module will be disabled');
// Uninstall all modules that can be uninstalled.
\Drupal::service('module_installer')->uninstall(array_keys($modules_to_uninstall));
......
......@@ -7,7 +7,6 @@
use Drupal\Core\Config\ConfigImporter;
use Drupal\Core\Entity\EntityTypeInterface;
use Drupal\Core\Entity\DynamicallyFieldableEntityStorageInterface;
use Drupal\Core\Extension\Extension;
use Drupal\field\Entity\FieldConfig;
use Drupal\Core\Form\FormStateInterface;
use Drupal\Core\Routing\RouteMatchInterface;
......@@ -145,40 +144,6 @@ function field_cron() {
field_purge_batch($limit);
}
/**
* Implements hook_system_info_alter().
*
* Goes through a list of all modules that provide a field type and makes them
* required if there are any active fields of that type.
*/
function field_system_info_alter(&$info, Extension $file, $type) {
// It is not safe to call entity_load_multiple_by_properties() during
// maintenance mode.
if ($type == 'module' && !defined('MAINTENANCE_MODE')) {
$field_storages = entity_load_multiple_by_properties('field_storage_config', array('module' => $file->getName(), 'include_deleted' => TRUE));
if ($field_storages) {
$info['required'] = TRUE;
// Provide an explanation message (only mention pending deletions if there
// remains no actual, non-deleted fields)
$non_deleted = FALSE;
foreach ($field_storages as $field_storage) {
if (!$field_storage->isDeleted()) {
$non_deleted = TRUE;
break;
}
}
if ($non_deleted) {
$explanation = t('Fields type(s) in use');
}
else {
$explanation = t('Fields pending deletion');
}
$info['explanation'] = $explanation;
}
}
}
/**
* Implements hook_entity_field_storage_info().
*/
......
services:
field.uninstall_validator:
class: Drupal\field\FieldUninstallValidator
tags:
- { name: module_install.uninstall_validator }
arguments: ['@entity.manager', '@string_translation']
lazy: true
<?php
/**
* @file
* Contains \Drupal\field\FieldUninstallValidator.
*/
namespace Drupal\field;
use Drupal\Core\Entity\EntityManagerInterface;
use Drupal\Core\Extension\ModuleUninstallValidatorInterface;
use Drupal\Core\StringTranslation\StringTranslationTrait;
use Drupal\Core\StringTranslation\TranslationInterface;
/**
* Prevents uninstallation of modules providing active field storage.
*/
class FieldUninstallValidator implements ModuleUninstallValidatorInterface {
use StringTranslationTrait;
/**
* The field storage config storage.
*
* @var \Drupal\Core\Config\Entity\ConfigEntityStorageInterface
*/
protected $fieldStorageConfigStorage;
/**
* Constructs a new FieldUninstallValidator.
*
* @param \Drupal\Core\Entity\EntityManagerInterface $entity_manager
* The entity manager.
* @param \Drupal\Core\StringTranslation\TranslationInterface $string_translation
* The string translation service.
*/
public function __construct(EntityManagerInterface $entity_manager, TranslationInterface $string_translation) {
$this->fieldStorageConfigStorage = $entity_manager->getStorage('field_storage_config');
$this->stringTranslation = $string_translation;
}
/**
* {@inheritdoc}
*/
public function validate($module) {
$reasons = [];
if ($field_storages = $this->getFieldStoragesByModule($module)) {
// Provide an explanation message (only mention pending deletions if there
// remain no actual, non-deleted fields.)
$non_deleted = FALSE;
foreach ($field_storages as $field_storage) {
if (!$field_storage->isDeleted()) {
$non_deleted = TRUE;
break;
}
}
if ($non_deleted) {
$reasons[] = $this->t('Fields type(s) in use');
}
else {
$reasons[] = $this->t('Fields pending deletion');
}
}
return $reasons;
}
/**
* Returns all field storages for a specified module.
*
* @param string $module
* The module to filter field storages by.
*
* @return \Drupal\field\FieldStorageConfigInterface[]
* An array of field storages for a specified module.
*/
protected function getFieldStoragesByModule($module) {
return $this->fieldStorageConfigStorage->loadByProperties(['module' => $module, 'include_deleted' => TRUE]);
}
}
......@@ -92,10 +92,10 @@ function testReEnabledField() {
// for it's fields.
$admin_user = $this->drupalCreateUser(array('access administration pages', 'administer modules'));
$this->drupalLogin($admin_user);
$this->drupalGet('admin/modules');
$this->drupalGet('admin/modules/uninstall');
$this->assertText('Fields type(s) in use');
$field_storage->delete();
$this->drupalGet('admin/modules');
$this->drupalGet('admin/modules/uninstall');
$this->assertText('Fields pending deletion');
$this->cronRun();
$this->assertNoText('Fields type(s) in use');
......
<?php
/**
* @file
* Contains \Drupal\Tests\field\Unit\FieldUninstallValidatorTest.
*/
namespace Drupal\Tests\field\Unit;
use Drupal\Tests\UnitTestCase;
/**
* @coversDefaultClass \Drupal\field\FieldUninstallValidator
* @group field
*/
class FieldUninstallValidatorTest extends UnitTestCase {
/**