diff --git a/core/lib/Drupal/Core/Config/ConfigImporter.php b/core/lib/Drupal/Core/Config/ConfigImporter.php index 2ccbf94..1e324cd 100644 --- a/core/lib/Drupal/Core/Config/ConfigImporter.php +++ b/core/lib/Drupal/Core/Config/ConfigImporter.php @@ -481,11 +481,11 @@ public function import() { * Exception thrown if the $sync_step can not be called. */ public function doSyncStep($sync_step, &$context) { - if (method_exists($this, $sync_step)) { + if (!is_array($sync_step) && method_exists($this, $sync_step)) { $this->$sync_step($context); } elseif (is_callable($sync_step)) { - call_user_func_array($sync_step, array(&$context)); + call_user_func_array($sync_step, array(&$context, $this)); } else { throw new \InvalidArgumentException('Invalid configuration synchronization step'); @@ -531,7 +531,7 @@ public function initialize() { $sync_steps[] = 'processConfigurations'; // Allow modules to add new steps to configuration synchronization. - $this->moduleHandler->alter('config_import_steps', $sync_steps); + $this->moduleHandler->alter('config_import_steps', $sync_steps, $this); $sync_steps[] = 'finish'; return $sync_steps; } diff --git a/core/modules/field/field.module b/core/modules/field/field.module index 029db41..fa53f0a 100644 --- a/core/modules/field/field.module +++ b/core/modules/field/field.module @@ -6,6 +6,7 @@ use Drupal\Component\Utility\Html; use Drupal\Component\Utility\Xss; +use Drupal\Core\Config\ConfigImporter; use Drupal\Core\Entity\EntityTypeInterface; use Drupal\Core\Extension\Extension; use Drupal\field\Field; @@ -362,3 +363,31 @@ function field_hook_info() { return $hooks; } + +/** + * Implements hook_config_import_steps_alter(). + */ +function field_config_import_steps_alter(&$sync_steps, ConfigImporter $config_importer) { + $fields = \Drupal\field\FieldConfigImporter::getFieldsToPurge($config_importer->getStorageComparer()); + if (count($fields)) { + array_unshift($sync_steps, array('\Drupal\field\FieldConfigImporter', 'processDeletedFields')); + }; +} + +/** + * Implements hook_form_FORM_ID_alter(). + * + * Adds a warning if field data will be permanently removed by the configuration + * synchronisation. + * + * @see \Drupal\field\FieldConfigImporter + */ +function field_form_config_admin_import_form_alter(&$form, &$form_state) { + if (isset($form_state['storage_comparer'])) { + $fields = \Drupal\field\FieldConfigImporter::getFieldsToPurge($form_state['storage_comparer']); + if (count($fields)) { + // @todo improve message and don't use dsm. + drupal_set_message(t('Field data will be deleted by this synchronisation.'), 'warning'); + } + } +} diff --git a/core/modules/field/lib/Drupal/field/Entity/FieldConfig.php b/core/modules/field/lib/Drupal/field/Entity/FieldConfig.php index da29536..e69811f 100644 --- a/core/modules/field/lib/Drupal/field/Entity/FieldConfig.php +++ b/core/modules/field/lib/Drupal/field/Entity/FieldConfig.php @@ -660,32 +660,57 @@ public static function getReservedColumns() { * TRUE if the field has data for any entity; FALSE otherwise. */ public function hasData() { - if ($this->getBundles()) { + return (bool) $this->numberOfRows(TRUE); + } + + /** + * Determines the number of rows of data this field has. + * + * @param bool $has_data + * (Optional) Optimises query for hasData(). + * + * @return int + * The number of rows of data for this field. If $has_data parameter is TRUE + * then the value will either be 0 or 1. + */ + public function numberOfRows($has_data = FALSE) { + $factory = \Drupal::service('entity.query'); + $entity_type = \Drupal::entityManager()->getDefinition($this->entity_type); + // Entity Query throws an exception if there is no base table. + if (!$entity_type->getBaseTable()) { + return 0; + } + + if ($this->deleted) { + $query = $factory->get($this->entity_type) + ->condition('id:' . $this->uuid() . '.deleted', 1) + ->count() + ->accessCheck(FALSE); + } + elseif ($this->getBundles()) { $storage_details = $this->getSchema(); $columns = array_keys($storage_details['columns']); - $factory = \Drupal::service('entity.query'); - // Entity Query throws an exception if there is no base table. - $entity_type = \Drupal::entityManager()->getDefinition($this->entity_type); - if (!$entity_type->getBaseTable()) { - return FALSE; - } + $query = $factory->get($this->entity_type); $group = $query->orConditionGroup(); foreach ($columns as $column) { $group->exists($this->name . '.' . $column); } - $result = $query + $query = $query ->condition($group) ->count() - ->accessCheck(FALSE) - ->range(0, 1) - ->execute(); - if ($result) { - return TRUE; - } + ->accessCheck(FALSE); } - return FALSE; + if (isset($query)) { + // If we are performing the query just to check if the field has data + // limit the number of rows returned by the subquery. + if ($has_data) { + $query->range(0, 1); + } + return (int) $query->execute(); + } + return 0; } /** diff --git a/core/modules/field/lib/Drupal/field/FieldConfigImporter.php b/core/modules/field/lib/Drupal/field/FieldConfigImporter.php new file mode 100644 index 0000000..bbf1dbb --- /dev/null +++ b/core/modules/field/lib/Drupal/field/FieldConfigImporter.php @@ -0,0 +1,129 @@ +getStorageComparer(), $config_importer->getUnprocessedConfiguration('delete')); + if (!isset($context['sandbox']['field']['current_field_id']) || $context['sandbox']['field']['current_field_id'] != $fields[0]->id()) { + $context['sandbox']['field']['current_field_id'] = $fields[0]->id(); + // If the field has not been deleted yet we need to do that. This is the + // case when the field deletion is staged. + if (!$fields[0]->deleted) { + $fields[0]->delete(); + } + } + field_purge_batch($context['sandbox']['field']['purge_batch_size']); + $context['sandbox']['field']['current_progress']++; + $fields_to_delete_count = count(static::getFieldsToPurge($config_importer->getStorageComparer(), $config_importer->getUnprocessedConfiguration('delete'))); + if ($fields_to_delete_count == 0) { + $context['finished'] = 1; + } + else { + $context['finished'] = $context['sandbox']['field']['current_progress'] / $context['sandbox']['field']['steps_to_delete']; + $context['message'] = \Drupal::translation()->translate('Purging field @field_label', array('@field_label' => $fields[0]->label())); + } + } + + /** + * Calculates the number of steps necessary to purge all the field data. + * + * @param array $context + * The batch context. + * @param \Drupal\Core\Config\ConfigImporter $config_importer + * The config importer. + */ + protected static function calculateNumberOfBatchSteps(&$context, ConfigImporter $config_importer) { + // @todo should we use the staged batch size or the currently active? + $context['sandbox']['field']['purge_batch_size'] = \Drupal::config('field.settings')->get('purge_batch_size'); + + $fields = static::getFieldsToPurge($config_importer->getStorageComparer(), $config_importer->getUnprocessedConfiguration('delete')); + // There will be one step to delete the instances and field. + $context['sandbox']['field']['steps_to_delete'] = count($fields); + foreach ($fields as $field) { + $row_count = $field->numberOfRows(); + if ($row_count > 0) { + // The number of steps to delete each field is determined by the + // purge_batch_size setting. For example if the field has 9 rows and the + // batch size is 10 then this will add 1 step to $number_of_steps. + $how_many_steps = (int) ($row_count / $context['sandbox']['field']['purge_batch_size']) + 1; + $context['sandbox']['field']['steps_to_delete'] += $how_many_steps; + } + } + $context['sandbox']['field']['current_progress'] = 0; + } + + /** + * Gets the list of fields to purge before configuration synchronisation. + * + * If, during a configuration synchronization, a field is being deleted and + * the module that provides the field type is being uninstalled then the field + * data must be purged before the module is uninstalled. Also, if deleted + * fields exist whose field types are provided by modules that are being + * uninstalled their data need to be purged too. + * + * @param \Drupal\Core\Config\StorageComparerInterface $storage_comparer + * The configuration storage comparer. + * @param array $deletes + * (Optional) The configuration to delete. Used to provide a current + * + * @return \Drupal\field\Entity\FieldConfig[] + * An array of fields that need purging before configuration can be + * synchronised. + */ + public static function getFieldsToPurge(StorageComparerInterface $storage_comparer, array $deletes = NULL) { + $fields_to_delete = array(); + if (!$deletes) { + $deletes = $storage_comparer->getChangelist('delete'); + } + $staged_extensions = $storage_comparer->getSourceStorage()->read('core.extension'); + + // Gather fields that will be deleted during configuration synchronization + // where the module that provides the field type is also being uninstalled. + foreach ($deletes as $config_name) { + if (strpos($config_name, 'field.field.') === 0) { + $id = ConfigEntityStorage::getIDFromConfigName($config_name, 'field.field'); + /** @var \Drupal\field\Entity\FieldConfig $field */ + $field = entity_load('field_config', $id); + if ($field && !isset($staged_extensions['module'][$field->module])) { + $fields_to_delete[] = $field; + } + } + } + + // Gather deleted fields from modules that 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])) { + $fields_to_delete[] = $field; + } + } + return $fields_to_delete; + } +} 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..ee5a037 --- /dev/null +++ b/core/modules/field/lib/Drupal/field/Tests/FieldImportDeleteUninstallTest.php @@ -0,0 +1,157 @@ + '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'); + + $steps = $this->configImporter()->initialize(); + $this->assertIdentical($steps[0], array('\Drupal\field\FieldConfigImporter', 'processDeletedFields'), 'The additional processDeletedFields configuration synchronization step has been added.'); + + $this->configImporter()->import(); + + // This will purge all the data, delete the field and uninstall the + // Telephone module. + $this->rebuildContainer(); + $this->assertFalse(\Drupal::moduleHandler()->moduleExists('telephone')); + $this->assertFalse(entity_load_by_uuid('field_config', $field_uuid), 'The test field has been deleted by the configuration synchronisation'); + $deleted_fields = \Drupal::state()->get('field.field.deleted') ?: array(); + $this->assertFalse(isset($deleted_fields[$field_uuid]), 'Field has been completed removed from the system.'); + } + + /** + * Tests purging already fields and instances as part of config import. + */ + public function testImportAlreadyDeletedUninstall() { + // 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(); + + // Create 12 entities to ensure that the purging works as expected. + for ($i=0; $i < 12; $i++) { + $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); + } + + // Delete the field. + $field->delete(); + + $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); + + $deleted_fields = \Drupal::state()->get('field.field.deleted') ?: array(); + $this->assertTrue(isset($deleted_fields[$field_uuid]), 'Field has been deleted and needs purging before configuration synchronization.'); + + $steps = $this->configImporter()->initialize(); + $this->assertIdentical($steps[0], array('\Drupal\field\FieldConfigImporter', 'processDeletedFields'), 'The additional processDeletedFields configuration synchronization step has been added.'); + + $this->configImporter()->import(); + + // This will purge all the data, delete the field and uninstall the + // Telephone module. + $this->rebuildContainer(); + $this->assertFalse(\Drupal::moduleHandler()->moduleExists('telephone')); + $deleted_fields = \Drupal::state()->get('field.field.deleted') ?: array(); + $this->assertFalse(isset($deleted_fields[$field_uuid]), 'Field has been completed removed from the system.'); + } +} diff --git a/core/modules/simpletest/lib/Drupal/simpletest/TestBase.php b/core/modules/simpletest/lib/Drupal/simpletest/TestBase.php index 4005305..88188db 100644 --- a/core/modules/simpletest/lib/Drupal/simpletest/TestBase.php +++ b/core/modules/simpletest/lib/Drupal/simpletest/TestBase.php @@ -1048,6 +1048,9 @@ private function prepareEnvironment() { $this->generatedTestFiles = FALSE; + // Ensure the configImporter is refreshed for each test. + $this->configImporter = NULL; + // Unregister all custom stream wrappers of the parent site. // Availability of Drupal stream wrappers varies by test base class: // - UnitTestBase operates in a completely empty environment. diff --git a/core/modules/system/system.api.php b/core/modules/system/system.api.php index 146fe2f..e420027 100644 --- a/core/modules/system/system.api.php +++ b/core/modules/system/system.api.php @@ -2899,8 +2899,11 @@ function hook_link_alter(&$variables) { * @see callback_batch_operation() * @see \Drupal\Core\Config\ConfigImporter::initialize() */ -function hook_config_import_steps_alter(&$sync_steps) { - $sync_steps[] = '_additional_configuration_step'; +function hook_config_import_steps_alter(&$sync_steps, \Drupal\Core\Config\ConfigImporter $config_importer) { + $deletes = $config_importer->getUnprocessedConfiguration('delete'); + if (isset($deletes['field.field.node.body'])) { + $sync_steps[] = '_additional_configuration_step'; + } } /**