diff --git a/core/modules/field/field.services.yml b/core/modules/field/field.services.yml index 4fc8d29..897d564 100644 --- a/core/modules/field/field.services.yml +++ b/core/modules/field/field.services.yml @@ -2,4 +2,8 @@ services: field.info: class: Drupal\field\FieldInfo arguments: ['@cache.default', '@config.factory', '@module_handler', '@plugin.manager.field.field_type', '@language_manager'] - + field.config_subscriber: + class: Drupal\field\FieldConfigSubscriber + arguments: ['@string_translation'] + tags: + - { name: event_subscriber } diff --git a/core/modules/field/lib/Drupal/field/FieldConfigSubscriber.php b/core/modules/field/lib/Drupal/field/FieldConfigSubscriber.php new file mode 100644 index 0000000..39b6bd6 --- /dev/null +++ b/core/modules/field/lib/Drupal/field/FieldConfigSubscriber.php @@ -0,0 +1,78 @@ +translationManager = $translation_manager; + } + + /** + * {@inheritdoc} + */ + static function getSubscribedEvents() { + $events[ConfigEvents::IMPORT_VALIDATE][] = array('onConfigImporterValidate', 20); + return $events; + } + + /** + * Checks that the field deletions and module uninstallations are compatible. + * + * @param ConfigImporterEvent $event + * The config import event. + * + * @throws \Drupal\Core\Config\ConfigImporterException + * Exception thrown if a field is being deleted and the module that provides + * it is also being uninstalled. Additionally the exception is thrown if + * deleted fields exist on the target site that are provided by modules + * scheduled for uninstallation. + */ + public function onConfigImporterValidate(ConfigImporterEvent $event) { + $deletes = $event->getConfigImporter()->getUnprocessedConfiguration('delete'); + $staged_extensions = $event->getConfigImporter()->getStorageComparer()->getSourceStorage()->read('core.extension'); + foreach ($deletes as $config_name) { + if (strpos($config_name, 'field.field.') === 0) { + // A field instance is being deleted. We have to ensure that the module + // that provides it is not also being uninstalled. + $id = ConfigEntityStorage::getIDFromConfigName($config_name, 'field.field'); + /** @var \Drupal\field\Entity\FieldConfig $field */ + $field = entity_load('field_config', $id); + if (!isset($staged_extensions['module'][$field->module])) { + throw new ConfigImporterException($this->translationManager->translate('This import is deleting field that has data and uninstalling the module that provides the field, so has been rejected.')); + } + } + } + + // Ensure no modules that provide deleted fields are being uninstalled. + $fields = entity_load_multiple_by_properties('field_config', array('deleted' => TRUE, 'include_deleted' => TRUE)); + foreach($fields as $field) { + if (!isset($staged_extensions['module'][$field->module])) { + throw new ConfigImporterException($this->translationManager->translate('This import is uninstalling the @module module which provides deleted fields, you need to purge the data first.', array('@module' => $field->module))); + } + } + } +} diff --git a/core/modules/field/lib/Drupal/field/Tests/FieldImportDeleteUninstallTest.php b/core/modules/field/lib/Drupal/field/Tests/FieldImportDeleteUninstallTest.php new file mode 100644 index 0000000..e4509ae --- /dev/null +++ b/core/modules/field/lib/Drupal/field/Tests/FieldImportDeleteUninstallTest.php @@ -0,0 +1,133 @@ + 'Field config delete and uninstall tests', + 'description' => 'Delete field and instances during config synchronsiaton and uninstall module that provides the field type.', + 'group' => 'Field API', + ); + } + + public function setUp() { + parent::setUp(); + // Module uninstall requires the router and users_data tables. + // @see drupal_flush_all_caches() + // @see user_modules_uninstalled() + $this->installSchema('system', array('router')); + $this->installSchema('user', array('users_data')); + } + + /** + * Tests deleting fields and instances as part of config import. + */ + public function testImportDeleteUninstall() { + // Create a telephone field and instance for validation. + $field = entity_create('field_config', array( + 'name' => 'field_test', + 'entity_type' => 'entity_test', + 'type' => 'telephone', + )); + $field->save(); + $field_uuid = $field->uuid(); + entity_create('field_instance_config', array( + 'entity_type' => 'entity_test', + 'field_name' => 'field_test', + 'bundle' => 'entity_test', + ))->save(); + + $entity = entity_create('entity_test'); + $value = '+0123456789'; + $entity->field_test = $value; + $entity->name->value = $this->randomName(); + $entity->save(); + + // Verify entity has been created properly. + $id = $entity->id(); + $entity = entity_load('entity_test', $id); + $this->assertEqual($entity->field_test->value, $value); + $this->assertEqual($entity->field_test[0]->value, $value); + + $active = $this->container->get('config.storage'); + $staging = $this->container->get('config.storage.staging'); + $this->copyConfig($active, $staging); + + // Stage uninstall of the Telephone module. + $core_extension = \Drupal::config('core.extension')->get(); + unset($core_extension['module']['telephone']); + $staging->write('core.extension', $core_extension); + + // Stage the field deletion + $staging->delete('field.field.entity_test.field_test'); + $staging->delete('field.instance.entity_test.entity_test.field_test'); + + try { + $this->configImporter()->import(); + $this->fail('Expected ConfigImporterException not thrown from \Drupal\field\FieldConfigSubscriber::onConfigImporterValidate()'); + } + catch (ConfigImporterException $e) { + $this->pass('Expected ConfigImporterException not thrown from \Drupal\field\FieldConfigSubscriber::onConfigImporterValidate()'); + $this->assertEqual($e->getMessage(), 'This import is deleting field that has data and uninstalling the module that provides the field, so has been rejected.'); + } + + // Unstage uninstall of the Telephone module. + $core_extension = \Drupal::config('core.extension')->get(); + $staging->write('core.extension', $core_extension); + + // This will delete the field and the field instance. + $this->configImporter()->import(); + + // Check that the field definition is preserved in state. + $deleted_fields = \Drupal::state()->get('field.field.deleted') ?: array(); + $this->assertTrue(isset($deleted_fields[$field_uuid]), 'Deleted field is present in state.'); + + // Stage uninstall of the Telephone module. + $core_extension = \Drupal::config('core.extension')->get(); + unset($core_extension['module']['telephone']); + $staging->write('core.extension', $core_extension); + + try { + $this->configImporter()->import(); + $this->fail('Expected ConfigImporterException not thrown from \Drupal\field\FieldConfigSubscriber::onConfigImporterValidate()'); + } + catch (ConfigImporterException $e) { + $this->pass('Expected ConfigImporterException not thrown from \Drupal\field\FieldConfigSubscriber::onConfigImporterValidate()'); + $this->assertEqual($e->getMessage(), 'This import is uninstalling the telephone module which provides deleted fields, you need to purge the data first.'); + } + + // Purge field data, and check that the field definition has been completely + // removed once the data is purged. + $this->assertTrue(\Drupal::moduleHandler()->moduleExists('telephone')); + // Need to call field_purge_batch twice. oNce to purge the data and the + // second time to actually delete the delete instance and field. + field_purge_batch(10); + field_purge_batch(10); + // This will uninstall the Telephone module. + $this->configImporter()->import(); + $this->rebuildContainer(); + $this->assertFalse(\Drupal::moduleHandler()->moduleExists('telephone')); + } + +}