Commit 59d5c098 authored by catch's avatar catch

Issue #2416409 by alexpott, bojanz: Delete dependent config entities that...

Issue #2416409 by alexpott, bojanz: Delete dependent config entities that don't implement onDependencyRemoval() when a config entity is deleted
parent 9f0db326
......@@ -413,12 +413,13 @@ protected function callOnDependencyRemoval(ConfigEntityInterface $entity, array
if ($type == 'config' || $type == 'content') {
$affected_dependencies[$type] = array_map(function ($name) use ($type) {
if ($type == 'config') {
$entity_type_id = $this->getEntityTypeIdByName($name);
return $this->loadConfigEntityByName($name);
}
else {
list($entity_type_id) = explode(':', $name);
// Ignore the bundle.
list($entity_type_id,, $uuid) = explode(':', $name);
return $this->entityManager->loadEntityByConfigTarget($entity_type_id, $uuid);
}
return $this->entityManager->loadEntityByConfigTarget($entity_type_id, $name);
}, $affected_dependencies[$type]);
}
}
......@@ -433,6 +434,14 @@ protected function callOnDependencyRemoval(ConfigEntityInterface $entity, array
}
}
// Key the entity arrays by config dependency name to make searching easy.
foreach (['config', 'content'] as $dependency_type) {
$affected_dependencies[$dependency_type] = array_combine(
array_map(function ($entity) { return $entity->getConfigDependencyName(); }, $affected_dependencies[$dependency_type]),
$affected_dependencies[$dependency_type]
);
}
// Inform the entity.
return $entity->onDependencyRemoval($affected_dependencies);
}
......
<?php
/**
* @file
* Contains \Drupal\Core\Config\Entity\ConfigDependencyDeleteFormTrait;
*/
namespace Drupal\Core\Config\Entity;
use Drupal\Core\Config\ConfigManagerInterface;
use Drupal\Core\Entity\EntityManagerInterface;
/**
* Lists affected configuration entities by a dependency removal.
*
* This trait relies on the StringTranslationTrait.
*/
trait ConfigDependencyDeleteFormTrait {
/**
* Translates a string to the current language or to a given language.
*
* Provided by \Drupal\Core\StringTranslation\StringTranslationTrait.
*/
abstract protected function t($string, array $args = array(), array $options = array());
/**
* Adds form elements to list affected configuration entities.
*
* @param array $form
* The form array to add elements to.
* @param string $type
* The type of dependency being checked. Either 'module', 'theme', 'config'
* or 'content'.
* @param array $names
* The specific names to check. If $type equals 'module' or 'theme' then it
* should be a list of module names or theme names. In the case of 'config'
* or 'content' it should be a list of configuration dependency names.
* @param \Drupal\Core\Config\ConfigManagerInterface $config_manager
* The config manager.
* @param \Drupal\Core\Entity\EntityManagerInterface $entity_manager
* The entity manager.
*
* @see \Drupal\Core\Config\ConfigManagerInterface::getConfigEntitiesToChangeOnDependencyRemoval()
*/
protected function addDependencyListsToForm(array &$form, $type, array $names, ConfigManagerInterface $config_manager, EntityManagerInterface $entity_manager) {
// Get the dependent entities.
$dependent_entities = $config_manager->getConfigEntitiesToChangeOnDependencyRemoval($type, $names);
$entity_types = array();
$form['entity_updates'] = array(
'#type' => 'details',
'#title' => $this->t('Configuration updates'),
'#description' => $this->t('The listed configuration will be updated.'),
'#collapsible' => TRUE,
'#collapsed' => TRUE,
'#access' => FALSE,
);
foreach ($dependent_entities['update'] as $entity) {
/** @var \Drupal\Core\Config\Entity\ConfigEntityInterface $entity */
$entity_type_id = $entity->getEntityTypeId();
if (!isset($form['entity_updates'][$entity_type_id])) {
$entity_type = $entity_manager->getDefinition($entity_type_id);
// Store the ID and label to sort the entity types and entities later.
$label = $entity_type->getLabel();
$entity_types[$entity_type_id] = $label;
$form['entity_updates'][$entity_type_id] = array(
'#theme' => 'item_list',
'#title' => $label,
'#items' => array(),
);
}
$form['entity_updates'][$entity_type_id]['#items'][] = $entity->label() ?: $entity->id();
}
if (!empty($dependent_entities['update'])) {
$form['entity_updates']['#access'] = TRUE;
// Add a weight key to the entity type sections.
asort($entity_types, SORT_FLAG_CASE);
$weight = 0;
foreach ($entity_types as $entity_type_id => $label) {
$form['entity_updates'][$entity_type_id]['#weight'] = $weight;
// Sort the list of entity labels alphabetically.
sort($form['entity_updates'][$entity_type_id]['#items'], SORT_FLAG_CASE);
$weight++;
}
}
$form['entity_deletes'] = array(
'#type' => 'details',
'#title' => $this->t('Configuration deletions'),
'#description' => $this->t('The listed configuration will be deleted.'),
'#collapsible' => TRUE,
'#collapsed' => TRUE,
'#access' => FALSE,
);
foreach ($dependent_entities['delete'] as $entity) {
$entity_type_id = $entity->getEntityTypeId();
if (!isset($form['entity_deletes'][$entity_type_id])) {
$entity_type = $entity_manager->getDefinition($entity_type_id);
// Store the ID and label to sort the entity types and entities later.
$label = $entity_type->getLabel();
$entity_types[$entity_type_id] = $label;
$form['entity_deletes'][$entity_type_id] = array(
'#theme' => 'item_list',
'#title' => $label,
'#items' => array(),
);
}
$form['entity_deletes'][$entity_type_id]['#items'][] = $entity->label() ?: $entity->id();
}
if (!empty($dependent_entities['delete'])) {
$form['entity_deletes']['#access'] = TRUE;
// Add a weight key to the entity type sections.
asort($entity_types, SORT_FLAG_CASE);
$weight = 0;
foreach ($entity_types as $entity_type_id => $label) {
$form['entity_deletes'][$entity_type_id]['#weight'] = $weight;
// Sort the list of entity labels alphabetically.
sort($form['entity_deletes'][$entity_type_id]['#items'], SORT_FLAG_CASE);
$weight++;
}
}
}
}
......@@ -9,6 +9,7 @@
use Drupal\Component\Utility\String;
use Drupal\Core\Cache\Cache;
use Drupal\Core\Config\ConfigException;
use Drupal\Core\Config\Schema\SchemaIncompleteException;
use Drupal\Core\Entity\Entity;
use Drupal\Core\Config\ConfigDuplicateUUIDException;
......@@ -517,6 +518,40 @@ public function getThirdPartyProviders() {
return array_keys($this->third_party_settings);
}
/**
* {@inheritdoc}
*/
public static function preDelete(EntityStorageInterface $storage, array $entities) {
parent::preDelete($storage, $entities);
foreach ($entities as $entity) {
if ($entity->isUninstalling() || $entity->isSyncing()) {
// During extension uninstall and configuration synchronization
// deletions are already managed.
break;
}
// Fix or remove any dependencies.
$config_entities = static::getConfigManager()->getConfigEntitiesToChangeOnDependencyRemoval('config', [$entity->getConfigDependencyName()], FALSE);
/** @var \Drupal\Core\Config\Entity\ConfigEntityInterface $dependent_entity */
foreach ($config_entities['update'] as $dependent_entity) {
$dependent_entity->save();
}
foreach ($config_entities['delete'] as $dependent_entity) {
$dependent_entity->delete();
}
}
}
/**
* Gets the configuration manager.
*
* @return \Drupal\Core\Config\ConfigManager
* The configuration manager.
*/
protected static function getConfigManager() {
return \Drupal::service('config.manager');
}
/**
* {@inheritdoc}
*/
......
......@@ -165,10 +165,6 @@ public function calculateDependencies();
* caller for the changes to take effect. Implementations should not save the
* entity.
*
* @todo https://www.drupal.org/node/2336727 this method is only fired during
* extension uninstallation but it could be used during config entity
* deletion too.
*
* @param array $dependencies
* An array of dependencies that will be deleted keyed by dependency type.
* Dependency types are, for example, entity, module and theme.
......@@ -176,7 +172,11 @@ public function calculateDependencies();
* @return bool
* TRUE if the entity has changed, FALSE if not.
*
* @return bool
* TRUE if the entity has been changed as a result, FALSE if not.
*
* @see \Drupal\Core\Config\Entity\ConfigDependencyManager
* @see \Drupal\Core\Config\ConfigEntityBase::preDelete()
* @see \Drupal\Core\Config\ConfigManager::uninstall()
* @see \Drupal\Core\Entity\EntityDisplayBase::onDependencyRemoval()
*/
......
......@@ -7,6 +7,9 @@
namespace Drupal\Core\Entity;
use Drupal\Core\Config\Entity\ConfigEntityInterface;
use Drupal\Core\Form\FormStateInterface;
/**
* Provides a generic base class for an entity deletion form.
*
......@@ -16,4 +19,34 @@ class EntityDeleteForm extends EntityConfirmFormBase {
use EntityDeleteFormTrait;
/**
* {@inheritdoc}
*/
public function buildForm(array $form, FormStateInterface $form_state) {
$form = parent::buildForm($form, $form_state);
$entity = $this->getEntity();
// Only do dependency processing for configuration entities. Whilst it is
// possible for a configuration entity to be dependent on a content entity,
// these dependencies are soft and content delete permissions are often
// given to more users. This method should not make assumptions that $entity
// is a configuration entity in case we decide to remove the following
// condition.
if (!($entity instanceof ConfigEntityInterface)) {
return $form;
}
$this->addDependencyListsToForm($form, $entity->getConfigDependencyKey(), [$entity->getConfigDependencyName()], $this->getConfigManager(), $this->entityManager);
return $form;
}
/**
* Gets the configuration manager.
*
* @return \Drupal\Core\Config\ConfigManager
* The configuration manager.
*/
protected function getConfigManager() {
return \Drupal::service('config.manager');
}
}
......@@ -7,6 +7,7 @@
namespace Drupal\Core\Entity;
use Drupal\Core\Config\Entity\ConfigDependencyDeleteFormTrait;
use Drupal\Core\Form\FormStateInterface;
/**
......@@ -18,13 +19,7 @@
* @ingroup entity_api
*/
trait EntityDeleteFormTrait {
/**
* Translates a string to the current language or to a given language.
*
* Provided by \Drupal\Core\StringTranslation\StringTranslationTrait.
*/
abstract protected function t($string, array $args = array(), array $options = array());
use ConfigDependencyDeleteFormTrait;
/**
* Returns the entity of this form.
......
......@@ -261,6 +261,17 @@ public function calculateDependencies() {
return $this->dependencies;
}
/**
* {@inheritdoc}
*/
public function onDependencyRemoval(array $dependencies) {
$field_type_manager = \Drupal::service('plugin.manager.field.field_type');
$definition = $field_type_manager->getDefinition($this->getType());
$changed = $definition['class']::onDependencyRemoval($this, $dependencies);
return $changed;
}
/**
* {@inheritdoc}
*/
......
......@@ -273,4 +273,11 @@ public static function calculateDependencies(FieldDefinitionInterface $field_def
return array();
}
/**
* {@inheritdoc}
*/
public static function onDependencyRemoval(FieldDefinitionInterface $field_definition, array $dependencies) {
return FALSE;
}
}
......@@ -405,4 +405,20 @@ public function fieldSettingsForm(array $form, FormStateInterface $form_state);
*/
public static function calculateDependencies(FieldDefinitionInterface $field_definition);
/**
* Informs the plugin that a dependency of the field will be deleted.
*
* @param \Drupal\Core\Field\FieldDefinitionInterface $field_definition
* The field definition.
* @param array $dependencies
* An array of dependencies that will be deleted keyed by dependency type.
* Dependency types are, for example, entity, module and theme.
*
* @return bool
* TRUE if the field definition has been changed as a result, FALSE if not.
*
* @see \Drupal\Core\Config\ConfigEntityInterface::onDependencyRemoval()
*/
public static function onDependencyRemoval(FieldDefinitionInterface $field_definition, array $dependencies);
}
......@@ -286,4 +286,25 @@ public static function calculateDependencies(FieldDefinitionInterface $field_def
return $dependencies;
}
/**
* {@inheritdoc}
*/
public static function onDependencyRemoval(FieldDefinitionInterface $field_definition, array $dependencies) {
$changed = FALSE;
if (!empty($field_definition->default_value)) {
$target_entity_type = \Drupal::entityManager()->getDefinition($field_definition->getFieldStorageDefinition()->getSetting('target_type'));
foreach ($field_definition->default_value as $key => $default_value) {
if (is_array($default_value) && isset($default_value['target_uuid'])) {
$entity = \Drupal::entityManager()->loadEntityByUuid($target_entity_type->id(), $default_value['target_uuid']);
// @see \Drupal\Core\Field\EntityReferenceFieldItemList::processDefaultValue()
if ($entity && isset($dependencies[$entity->getConfigDependencyKey()][$entity->getConfigDependencyName()])) {
unset($field_definition->default_value[$key]);
$changed = TRUE;
}
}
}
}
return $changed;
}
}
......@@ -272,6 +272,106 @@ public function testConfigEntityUninstall() {
$this->assertFalse($storage->load('entity4'), 'Entity 4 deleted');
}
/**
* Tests deleting a configuration entity and dependency management.
*/
public function testConfigEntityDelete() {
/** @var \Drupal\Core\Config\ConfigManagerInterface $config_manager */
$config_manager = \Drupal::service('config.manager');
/** @var \Drupal\Core\Config\Entity\ConfigEntityStorage $storage */
$storage = $this->container->get('entity.manager')->getStorage('config_test');
// Test dependencies between configuration entities.
$entity1 = $storage->create(
array(
'id' => 'entity1'
)
);
$entity1->save();
$entity2 = $storage->create(
array(
'id' => 'entity2',
'dependencies' => array(
'enforced' => array(
'config' => array($entity1->getConfigDependencyName()),
),
),
)
);
$entity2->save();
// Do a dry run using
// \Drupal\Core\Config\ConfigManager::getConfigEntitiesToChangeOnDependencyRemoval().
$config_entities = $config_manager->getConfigEntitiesToChangeOnDependencyRemoval('config', [$entity1->getConfigDependencyName()]);
$this->assertEqual($entity2->uuid(), reset($config_entities['delete'])->uuid(), 'Entity 2 will be deleted.');
$this->assertTrue(empty($config_entities['update']), 'No dependent configuration entities will be updated.');
$this->assertTrue(empty($config_entities['unchanged']), 'No dependent configuration entities will be unchanged.');
// Test that doing a delete of entity1 deletes entity2 since it is dependent
// on entity1.
$entity1->delete();
$this->assertFalse($storage->load('entity1'), 'Entity 1 deleted');
$this->assertFalse($storage->load('entity2'), 'Entity 2 deleted');
// Set a more complicated test where dependencies will be fixed.
\Drupal::state()->set('config_test.fix_dependencies', array($entity1->getConfigDependencyName()));
// Entity1 will be deleted by the test.
$entity1 = $storage->create(
array(
'id' => 'entity1',
)
);
$entity1->save();
// Entity2 has a dependency on Entity1 but it can be fixed because
// \Drupal\config_test\Entity::onDependencyRemoval() will remove the
// dependency before config entities are deleted.
$entity2 = $storage->create(
array(
'id' => 'entity2',
'dependencies' => array(
'enforced' => array(
'config' => array($entity1->getConfigDependencyName()),
),
),
)
);
$entity2->save();
// Entity3 will be unchanged because it is dependent on Entity2 which can
// be fixed.
$entity3 = $storage->create(
array(
'id' => 'entity3',
'dependencies' => array(
'enforced' => array(
'config' => array($entity2->getConfigDependencyName()),
),
),
)
);
$entity3->save();
// Do a dry run using
// \Drupal\Core\Config\ConfigManager::getConfigEntitiesToChangeOnDependencyRemoval().
$config_entities = $config_manager->getConfigEntitiesToChangeOnDependencyRemoval('config', [$entity1->getConfigDependencyName()]);
$this->assertTrue(empty($config_entities['delete']), 'No dependent configuration entities will be deleted.');
$this->assertEqual($entity2->uuid(), reset($config_entities['update'])->uuid(), 'Entity 2 will be updated.');
$this->assertEqual($entity3->uuid(), reset($config_entities['unchanged'])->uuid(), 'Entity 3 is not changed.');
// Perform the uninstall.
$entity1->delete();
// Test that expected actions have been performed.
$this->assertFalse($storage->load('entity1'), 'Entity 1 deleted');
$entity2 = $storage->load('entity2');
$this->assertTrue($entity2, 'Entity 2 not deleted');
$this->assertEqual($entity2->calculateDependencies()['config'], array(), 'Entity 2 dependencies updated to remove dependency on Entity1.');
$entity3 = $storage->load('entity3');
$this->assertTrue($entity3, 'Entity 3 not deleted');
$this->assertEqual($entity3->calculateDependencies()['config'], [$entity2->getConfigDependencyName()], 'Entity 3 still depends on Entity 2.');
}
/**
* Tests getConfigEntitiesToChangeOnDependencyRemoval() with content entities.
*
......
<?php
/**
* @file
* Contains \Drupal\config\Tests\ConfigDependencyWebTest.
*/
namespace Drupal\config\Tests;
use Drupal\Core\Config\Entity\ConfigEntityStorage;
use Drupal\simpletest\WebTestBase;
/**
* Tests configuration entities.
*
* @group config
*/
class ConfigDependencyWebTest extends WebTestBase {
/**
* The maximum length for the entity storage used in this test.
*/
const MAX_ID_LENGTH = ConfigEntityStorage::MAX_ID_LENGTH;
/**
* Modules to enable.
*
* @var array
*/
public static $modules = array('config_test');
/**
* Tests ConfigDependencyDeleteFormTrait.
*
* @see \Drupal\Core\Config\Entity\ConfigDependencyDeleteFormTrait
*/
function testConfigDependencyDeleteFormTrait() {
/** @var \Drupal\Core\Config\Entity\ConfigEntityStorage $storage */
$storage = $this->container->get('entity.manager')->getStorage('config_test');
// Entity1 will be deleted by the test.
$entity1 = $storage->create(
array(
'id' => 'entity1',
'label' => 'Entity One',
)
);
$entity1->save();
// Entity2 has a dependency on Entity1 but it can be fixed because
// \Drupal\config_test\Entity::onDependencyRemoval() will remove the
// dependency before config entities are deleted.
$entity2 = $storage->create(
array(
'id' => 'entity2',
'dependencies' => array(
'enforced' => array(
'config' => array($entity1->getConfigDependencyName()),
),
),
)
);
$entity2->save();
$this->drupalGet($entity2->urlInfo('delete-form'));
$this->assertNoText(t('Configuration updates'), 'No configuration updates found.');
$this->assertNoText(t('Configuration deletions'), 'No configuration deletes found.');
$this->drupalGet($entity1->urlInfo('delete-form'));
$this->assertNoText(t('Configuration updates'), 'No configuration updates found.');
$this->assertText(t('Configuration deletions'), 'Configuration deletions found.');
$this->assertText($entity2->id(), 'Entity2 id found');
$this->drupalPostForm($entity1->urlInfo('delete-form'), array(), 'Delete');
$storage->resetCache();
$this->assertFalse($storage->loadMultiple([$entity1->id(), $entity2->id()]), 'Test entities deleted');
// Set a more complicated test where dependencies will be fixed.
// Entity1 will be deleted by the test.
$entity1 = $storage->create(
array(
'id' => 'entity1',
)
);
$entity1->save();
\Drupal::state()->set('config_test.fix_dependencies', array($entity1->getConfigDependencyName()));
// Entity2 has a dependency on Entity1 but it can be fixed because
// \Drupal\config_test\Entity::onDependencyRemoval() will remove the
// dependency before config entities are deleted.
$entity2 = $storage->create(
array(
'id' => 'entity2',
'label' => 'Entity Two',
'dependencies' => array(
'enforced' => array(
'config' => array($entity1->getConfigDependencyName()),
),
),
)
);
$entity2->save();
// Entity3 will be unchanged because it is dependent on Entity2 which can
// be fixed.
$entity3 = $storage->create(
array(
'id' => 'entity3',
'dependencies' => array(
'enforced' => array(
'config' => array($entity2->getConfigDependencyName()),
),
),
)
);
$entity3->save();
$this->drupalGet($entity1->urlInfo('delete-form'));
$this->assertText(t('Configuration updates'), 'Configuration updates found.');
$this->assertNoText(t('Configuration deletions'), 'No configuration deletions found.');
$this->assertNoText($entity2->id(), 'Entity2 id not found');
$this->assertText($entity2->label(), 'Entity2 label not found');
$this->assertNoText($entity3->id(), 'Entity3 id not found');
$this->drupalPostForm($entity1->urlInfo('delete-form'), array(), 'Delete');
$storage->resetCache();
$this->assertFalse($storage->load('entity1'), 'Test entity 1 deleted');
$entity2 = $storage->load('entity2');
$this->assertTrue($entity2, 'Entity 2 not deleted');
$this->assertEqual($entity2->calculateDependencies()['config'], array(), 'Entity 2 dependencies updated to remove dependency on Entity1.');
$entity3 = $storage->load('entity3');
$this->assertTrue($entity3, 'Entity 3 not deleted');
$this->assertEqual($entity3->calculateDependencies()['config'], [$entity2->getConfigDependencyName()], 'Entity 3 still depends on Entity 2.');
}
}
......@@ -381,6 +381,10 @@ function testSecondaryUpdateDeletedDeleterFirst() {
/**
* Tests that secondary updates for deleted files work as expected.
*
* This test is completely hypothetical since we only support full
* configuration tree imports. Therefore, any configuration updates that cause
* secondary deletes should be reflected already in the staged configuration.
*/
function testSecondaryUpdateDeletedDeleteeFirst() {
$name_deleter = 'config_test.dynamic.deleter';
......@@ -416,13 +420,10 @@ function testSecondaryUpdateDeletedDeleteeFirst() {
$this->configImporter->reset()->import();
$entity_storage = \Drupal::entityManager()->getStorage('config_test');
$deleter = $entity_storage->load('deleter');
$this->assertEqual($deleter->id(), 'deleter');
$this->assertEqual($deleter->uuid(), $values_deleter['uuid']);
$this->assertEqual($deleter->label(), $values_deleter['label']);
// @todo The deletee entity does not exist as the update worked but the
// entity was deleted after that. There is also no log message as this
// happened outside of the config importer.
// Both entities are deleted. ConfigTest::postSave() causes updates of the
// deleter entity to delete the deletee entity. Since the deleter depends on
// the deletee, removing the deletee causes the deleter to be removed.
$this->assertFalse($entity_storage->load('deleter'));
$this->assertFalse($entity_storage->load('deletee'));
$logs = $this->configImporter->getErrors();
$this->assertEqual(count($logs), 0);
......
......@@ -9,6 +9,7 @@
use Drupal\Component\Utility\Unicode;
use Drupal\config\Tests\SchemaCheckTestTrait;
use Drupal\field\Entity\FieldStorageConfig;
use Drupal\simpletest\WebTestBase;
/**
......@@ -105,4 +106,55 @@ function testEntityReferenceDefaultValue() {
$this->assertConfigSchema(\Drupal::service('config.typed'), $field_storage_config->getName(), $field_storage_config->get());
}
/**
* Tests that dependencies due to default values can be removed.
*
* @see \Drupal\Core\Field\Plugin\Field\FieldType\EntityReferenceItem::onDependencyRemoval()
*/
function testEntityReferenceDefaultConfigValue() {
// Create a node to be referenced.
$referenced_node_type = $this->drupalCreateContentType(array('type' => 'referenced_config_to_delete'));
$referenced_node_type2 = $this->drupalCreateContentType(array('type' => 'referenced_config_to_preserve'));
$field_name = Unicode::strtolower($this->randomMachineName());
$field_storage = entity_create('field_storage_config', array(
'field_name' => $field_name,
'entity_type' => 'node',
'type' => 'entity_reference',
'settings' => array('target_type' => 'node_type'),
'cardinality' => FieldStorageConfig::CARDINALITY_UNLIMITED,
));
$field_storage->save();
$field = entity_create('field_config', array(
'field_storage' => $field_storage,
'bundle' => 'reference_content',
'settings' => array(
'handler' => 'default',
'handler_settings' => array(
'sort' => array('field' => '_none'),
),
),
));
$field->save();
// Set created node as default_value.