diff --git a/core/core.services.yml b/core/core.services.yml index 60fd6d5..8a8f5f1 100644 --- a/core/core.services.yml +++ b/core/core.services.yml @@ -234,6 +234,10 @@ services: - { name: event_subscriber } - { name: service_collector, tag: 'config.factory.override', call: addOverride } arguments: ['@config.storage', '@event_dispatcher', '@config.typed'] + config.importer_subscriber: + class: Drupal\Core\Config\Importer\FinalMissingContentSubscriber + tags: + - { name: event_subscriber } config.installer: class: Drupal\Core\Config\ConfigInstaller arguments: ['@config.factory', '@config.storage', '@config.typed', '@config.manager', '@event_dispatcher'] diff --git a/core/lib/Drupal/Core/Config/ConfigEvents.php b/core/lib/Drupal/Core/Config/ConfigEvents.php index 0ba9075..486feba 100644 --- a/core/lib/Drupal/Core/Config/ConfigEvents.php +++ b/core/lib/Drupal/Core/Config/ConfigEvents.php @@ -99,6 +99,23 @@ const IMPORT = 'config.importer.import'; /** + * Name of event fired when missing content dependencies are detected. + * + * Events subscribers are fired as part of the configuration import batch. + * Each subscribe should call + * \Drupal\Core\Config\MissingContentEvent::resolveMissingContent() when they + * address a missing dependency. To address large amounts of dependencies + * subscribers can call + * \Drupal\Core\Config\MissingContentEvent::stopPropagation() which will stop + * calling other events and guarantee that the configuration import batch will + * fire the event again to continue processing missing content dependencies. + * + * @see \Drupal\Core\Config\ConfigImporter::processMissingContentDependencies() + * @see \Drupal\Core\Config\MissingContentEvent + */ + const IMPORT_MISSING_CONTENT = 'config.importer.missing_content'; + + /** * Name of event fired to collect information on all config collections. * * This event allows modules to add to the list of configuration collections diff --git a/core/lib/Drupal/Core/Config/ConfigImporter.php b/core/lib/Drupal/Core/Config/ConfigImporter.php index 4f28425..9e73342 100644 --- a/core/lib/Drupal/Core/Config/ConfigImporter.php +++ b/core/lib/Drupal/Core/Config/ConfigImporter.php @@ -7,6 +7,7 @@ namespace Drupal\Core\Config; +use Drupal\Core\Config\Importer\MissingContentEvent; use Drupal\Core\Extension\ModuleHandlerInterface; use Drupal\Core\Extension\ModuleInstallerInterface; use Drupal\Core\Extension\ThemeHandlerInterface; @@ -543,7 +544,7 @@ public function initialize() { $sync_steps[] = 'processExtensions'; } $sync_steps[] = 'processConfigurations'; - + $sync_steps[] = 'processMissingContent'; // Allow modules to add new steps to configuration synchronization. $this->moduleHandler->alter('config_import_steps', $sync_steps, $this); $sync_steps[] = 'finish'; @@ -614,6 +615,38 @@ protected function processConfigurations(array &$context) { } /** + * Handles processing of missing content. + * + * @param array $context + * Standard batch context. + */ + protected function processMissingContent(array &$context) { + $sandbox = &$context['sandbox']['config']; + if (!isset($sandbox['missing_content'])) { + $missing_content = $this->configManager->findMissingContentDependencies(); + $sandbox['missing_content']['data'] = $missing_content; + $sandbox['missing_content']['total'] = count($missing_content); + } + else { + $missing_content = $sandbox['missing_content']['data']; + } + if (!empty($missing_content)) { + $event = new MissingContentEvent($missing_content); + // Fire an event to allow listeners to create the missing content. + $this->eventDispatcher->dispatch(ConfigEvents::IMPORT_MISSING_CONTENT, $event); + $sandbox['missing_content']['data'] = $event->getMissingContent(); + } + $current_count = count($sandbox['missing_content']['data']); + if ($current_count) { + $context['message'] = $this->t('Resolving missing content'); + $context['finished'] = ($sandbox['missing_content']['total'] - $current_count) / $sandbox['missing_content']['total']; + } + else { + $context['finished'] = 1; + } + } + + /** * Finishes the batch. * * @param array $context. diff --git a/core/lib/Drupal/Core/Config/ConfigManager.php b/core/lib/Drupal/Core/Config/ConfigManager.php index 1d0d71f..31993aa 100644 --- a/core/lib/Drupal/Core/Config/ConfigManager.php +++ b/core/lib/Drupal/Core/Config/ConfigManager.php @@ -447,4 +447,29 @@ protected function callOnDependencyRemoval(ConfigEntityInterface $entity, array return $entity->onDependencyRemoval($affected_dependencies); } + /** + * {@inheritdoc} + */ + public function findMissingContentDependencies() { + $content_dependencies = array(); + $missing_dependencies = array(); + foreach ($this->activeStorage->readMultiple($this->activeStorage->listAll()) as $config_data) { + if (isset($config_data['dependencies']['content'])) { + $content_dependencies = array_merge($content_dependencies, $config_data['dependencies']['content']); + } + } + foreach (array_unique($content_dependencies) as $content_dependency) { + // Format of the dependency is entity_type:bundle:uuid. + list($entity_type, $bundle, $uuid) = explode(':', $content_dependency, 3); + if (!$this->entityManager->loadEntityByUuid($entity_type, $uuid)) { + $missing_dependencies[$uuid] = array( + 'entity_type' => $entity_type, + 'bundle' => $bundle, + 'uuid' => $uuid, + ); + } + } + return $missing_dependencies; + } + } diff --git a/core/lib/Drupal/Core/Config/ConfigManagerInterface.php b/core/lib/Drupal/Core/Config/ConfigManagerInterface.php index 5f55447..7da86f7 100644 --- a/core/lib/Drupal/Core/Config/ConfigManagerInterface.php +++ b/core/lib/Drupal/Core/Config/ConfigManagerInterface.php @@ -180,4 +180,14 @@ public function supportsConfigurationEntities($collection); */ public function getConfigCollectionInfo(); + /** + * Finds missing content dependencies declared in configuration entities. + * + * @return array + * A list of missing content dependencies. The array is keyed by UUID. Each + * value is an array with the following keys: 'entity_type', 'bundle' and + * 'uuid'. + */ + public function findMissingContentDependencies(); + } diff --git a/core/lib/Drupal/Core/Config/Importer/FinalMissingContentSubscriber.php b/core/lib/Drupal/Core/Config/Importer/FinalMissingContentSubscriber.php new file mode 100644 index 0000000..592ab5f --- /dev/null +++ b/core/lib/Drupal/Core/Config/Importer/FinalMissingContentSubscriber.php @@ -0,0 +1,45 @@ +getMissingContent()) as $uuid) { + $event->resolveMissingContent($uuid); + } + } + + /** + * {@inheritdoc} + */ + public static function getSubscribedEvents() { + // This should always be the final event as it will mark all content + // dependencies as resolved. + $events[ConfigEvents::IMPORT_MISSING_CONTENT][] = array('onMissingContent', -1024); + return $events; + } + +} diff --git a/core/lib/Drupal/Core/Config/Importer/MissingContentEvent.php b/core/lib/Drupal/Core/Config/Importer/MissingContentEvent.php new file mode 100644 index 0000000..e7cef8d --- /dev/null +++ b/core/lib/Drupal/Core/Config/Importer/MissingContentEvent.php @@ -0,0 +1,64 @@ +missingContent = $missing_content; + } + + /** + * Gets missing content information. + * + * @return array + * A list of missing content dependencies. The array is keyed by UUID. Each + * value is an array with the following keys: 'entity_type', 'bundle' and + * 'uuid'. + */ + public function getMissingContent() { + return $this->missingContent; + } + + /** + * Resolves the missing content by removing it from the list. + * + * @param string $uuid + * The UUID of the content entity to mark resolved. + * + * @return $this + * The MissingContentEvent object. + */ + public function resolveMissingContent($uuid) { + if (isset($this->missingContent[$uuid])) { + unset($this->missingContent[$uuid]); + } + return $this; + } + +} diff --git a/core/modules/config/src/Tests/ConfigDependencyTest.php b/core/modules/config/src/Tests/ConfigDependencyTest.php index fc85523..35b176c 100644 --- a/core/modules/config/src/Tests/ConfigDependencyTest.php +++ b/core/modules/config/src/Tests/ConfigDependencyTest.php @@ -8,21 +8,23 @@ namespace Drupal\config\Tests; use Drupal\entity_test\Entity\EntityTest; -use Drupal\simpletest\KernelTestBase; +use Drupal\system\Tests\Entity\EntityUnitTestBase; /** * Tests for configuration dependencies. * * @group config */ -class ConfigDependencyTest extends KernelTestBase { +class ConfigDependencyTest extends EntityUnitTestBase { /** * Modules to enable. * + * The entity_test module is enabled to provide content entity types. + * * @var array */ - public static $modules = array('system', 'config_test', 'entity_test', 'user'); + public static $modules = array('config_test', 'entity_test', 'user'); /** * Tests that calculating dependencies for system module. @@ -41,7 +43,8 @@ public function testNonEntity() { /** * Tests creating dependencies on configuration entities. */ - public function testDependencyMangement() { + public function testDependencyManagement() { + /** @var \Drupal\Core\Config\ConfigManagerInterface $config_manager */ $config_manager = \Drupal::service('config.manager'); $storage = $this->container->get('entity.manager')->getStorage('config_test'); // Test dependencies between modules. @@ -110,9 +113,14 @@ public function testDependencyMangement() { $this->assertTrue(isset($dependents['config_test.dynamic.entity3']), 'config_test.dynamic.entity3 has a dependency on the Node module.'); $this->assertTrue(isset($dependents['config_test.dynamic.entity4']), 'config_test.dynamic.entity4 has a dependency on the Node module.'); - // Test dependency on a fake content entity. - $entity2->setEnforcedDependencies(['config' => [$entity1->getConfigDependencyName()], 'content' => ['node:page:uuid']])->save();; - $dependents = $config_manager->findConfigEntityDependents('content', array('node:page:uuid')); + // Test dependency on a content entity. + $entity_test = entity_create('entity_test', array( + 'name' => $this->randomString(), + 'type' => 'entity_test', + )); + $entity_test->save(); + $entity2->setEnforcedDependencies(['config' => [$entity1->getConfigDependencyName()], 'content' => [$entity_test->getConfigDependencyName()]])->save();; + $dependents = $config_manager->findConfigEntityDependents('content', array($entity_test->getConfigDependencyName())); $this->assertFalse(isset($dependents['config_test.dynamic.entity1']), 'config_test.dynamic.entity1 does not have a dependency on the content entity.'); $this->assertTrue(isset($dependents['config_test.dynamic.entity2']), 'config_test.dynamic.entity2 has a dependency on the content entity.'); $this->assertTrue(isset($dependents['config_test.dynamic.entity3']), 'config_test.dynamic.entity3 has a dependency on the content entity (via entity2).'); @@ -151,6 +159,30 @@ public function testDependencyMangement() { $this->assertTrue(in_array('config_query_test:entity1', $dependent_ids), 'config_test.query.entity1 has a dependency on config_test module.'); $this->assertTrue(in_array('config_query_test:entity2', $dependent_ids), 'config_test.query.entity2 has a dependency on config_test module.'); + // Test the ability to find missing content dependencies. + $missing_dependencies = $config_manager->findMissingContentDependencies(); + $this->assertEqual([], $missing_dependencies); + + $expected = [$entity_test->uuid() => [ + 'entity_type' => 'entity_test', + 'bundle' => $entity_test->bundle(), + 'uuid' => $entity_test->uuid(), + ]]; + // Delete the content entity so that is it now missing. + $entity_test->delete(); + $missing_dependencies = $config_manager->findMissingContentDependencies(); + $this->assertEqual($expected, $missing_dependencies); + + // Add a fake missing dependency to ensure multiple missing dependencies + // work. + $entity1->setEnforcedDependencies(['content' => [$entity_test->getConfigDependencyName(), 'entity_test:bundle:uuid']])->save();; + $expected['uuid'] = [ + 'entity_type' => 'entity_test', + 'bundle' => 'bundle', + 'uuid' => 'uuid', + ]; + $missing_dependencies = $config_manager->findMissingContentDependencies(); + $this->assertEqual($expected, $missing_dependencies); } /** diff --git a/core/modules/config/src/Tests/ConfigImporterMissingContentTest.php b/core/modules/config/src/Tests/ConfigImporterMissingContentTest.php new file mode 100644 index 0000000..5575c8c --- /dev/null +++ b/core/modules/config/src/Tests/ConfigImporterMissingContentTest.php @@ -0,0 +1,95 @@ +installSchema('system', 'sequences'); + $this->installEntitySchema('entity_test'); + $this->installEntitySchema('user'); + $this->installConfig(array('config_test')); + // Installing config_test's default configuration pollutes the global + // variable being used for recording hook invocations by this test already, + // so it has to be cleared out manually. + unset($GLOBALS['hook_config_test']); + + $this->copyConfig($this->container->get('config.storage'), $this->container->get('config.storage.staging')); + + // Set up the ConfigImporter object for testing. + $storage_comparer = new StorageComparer( + $this->container->get('config.storage.staging'), + $this->container->get('config.storage'), + $this->container->get('config.manager') + ); + $this->configImporter = new ConfigImporter( + $storage_comparer->createChangelist(), + $this->container->get('event_dispatcher'), + $this->container->get('config.manager'), + $this->container->get('lock'), + $this->container->get('config.typed'), + $this->container->get('module_handler'), + $this->container->get('module_installer'), + $this->container->get('theme_handler'), + $this->container->get('string_translation') + ); + } + + /** + * Tests the missing content event is fired. + */ + function testMissingContent() { + \Drupal::state()->set('config_import_test.config_import_missing_content', TRUE); + + // Update a configuration entity in the staging directory to have a + // dependency on two content entities that do not exist. + $storage = $this->container->get('config.storage'); + $staging = $this->container->get('config.storage.staging'); + $entity_one = entity_create('entity_test', array('name' => 'one')); + $entity_two = entity_create('entity_test', array('name' => 'one')); + $dynamic_name = 'config_test.dynamic.dotted.default'; + $original_dynamic_data = $storage->read($dynamic_name); + $original_dynamic_data['dependencies']['content'][] = $entity_one->getConfigDependencyName(); + $original_dynamic_data['dependencies']['content'][] = $entity_two->getConfigDependencyName(); + $staging->write($dynamic_name, $original_dynamic_data); + + // Import. + $this->configImporter->reset()->import(); + $this->assertEqual($entity_one->uuid(), \Drupal::state()->get('config_import_test.config_import_missing_content_one'), 'The missing content event is fired during configuration import.'); + $this->assertEqual($entity_two->uuid(), \Drupal::state()->get('config_import_test.config_import_missing_content_two'), 'The missing content event is fired during configuration import.'); + $original_dynamic_data = $storage->read($dynamic_name); + $this->assertEqual([$entity_one->getConfigDependencyName(), $entity_two->getConfigDependencyName()], $original_dynamic_data['dependencies']['content'], 'The imported configuration entity has the missing content entity dependency.'); + } + +} diff --git a/core/modules/config/tests/config_import_test/src/EventSubscriber.php b/core/modules/config/tests/config_import_test/src/EventSubscriber.php index 0be4f97..297e5f5 100644 --- a/core/modules/config/tests/config_import_test/src/EventSubscriber.php +++ b/core/modules/config/tests/config_import_test/src/EventSubscriber.php @@ -10,6 +10,7 @@ use Drupal\Core\Config\ConfigCrudEvent; use Drupal\Core\Config\ConfigEvents; use Drupal\Core\Config\ConfigImporterEvent; +use Drupal\Core\Config\Importer\MissingContentEvent; use Drupal\Core\State\StateInterface; use Symfony\Component\EventDispatcher\EventSubscriberInterface; @@ -52,6 +53,39 @@ public function onConfigImporterValidate(ConfigImporterEvent $event) { } /** + * Handles the missing content event. + * + * @param \Drupal\Core\Config\Importer\MissingContentEvent $event + * The missing content event. + */ + public function onConfigImporterMissingContentOne(MissingContentEvent $event) { + if ($this->state->get('config_import_test.config_import_missing_content', FALSE) && $this->state->get('config_import_test.config_import_missing_content_one', FALSE) === FALSE) { + $missing = $event->getMissingContent(); + $uuid = key($missing); + $this->state->set('config_import_test.config_import_missing_content_one', key($missing)); + $event->resolveMissingContent($uuid); + // Stopping propagation ensures that onConfigImporterMissingContentTwo + // will be fired on the next batch step. + $event->stopPropagation(); + } + } + + /** + * Handles the missing content event. + * + * @param \Drupal\Core\Config\Importer\MissingContentEvent $event + * The missing content event. + */ + public function onConfigImporterMissingContentTwo(MissingContentEvent $event) { + if ($this->state->get('config_import_test.config_import_missing_content', FALSE)) { + $missing = $event->getMissingContent(); + $uuid = key($missing); + $this->state->set('config_import_test.config_import_missing_content_two', key($missing)); + $event->resolveMissingContent($uuid); + } + } + + /** * Reacts to a config save and records information in state for testing. * * @param \Drupal\Core\Config\ConfigCrudEvent $event @@ -106,6 +140,7 @@ static function getSubscribedEvents() { $events[ConfigEvents::SAVE][] = array('onConfigSave', 40); $events[ConfigEvents::DELETE][] = array('onConfigDelete', 40); $events[ConfigEvents::IMPORT_VALIDATE] = array('onConfigImporterValidate'); + $events[ConfigEvents::IMPORT_MISSING_CONTENT] = array(array('onConfigImporterMissingContentOne'), array('onConfigImporterMissingContentTwo', -100)); return $events; }