Issue #2361423 by alexpott, larowlan, dixon_, swentel: Add a step to config import to allow contrib to create content based on config dependencies

8.0.x
Nathaniel Catchpole 2015-05-20 14:22:10 +01:00
parent 7a72d33bbc
commit 66c9f700d9
10 changed files with 380 additions and 8 deletions

View File

@ -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']

View File

@ -98,6 +98,23 @@ final class ConfigEvents {
*/
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::processMissingContent()
* @see \Drupal\Core\Config\MissingContentEvent
*/
const IMPORT_MISSING_CONTENT = 'config.importer.missing_content';
/**
* Name of event fired to collect information on all config collections.
*

View File

@ -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;
@ -536,7 +537,7 @@ class ConfigImporter {
$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';
@ -606,6 +607,38 @@ class ConfigImporter {
}
}
/**
* 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.
*

View File

@ -447,4 +447,29 @@ class ConfigManager implements ConfigManagerInterface {
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;
}
}

View File

@ -180,4 +180,14 @@ interface ConfigManagerInterface {
*/
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();
}

View File

@ -0,0 +1,45 @@
<?php
/**
* @file
* Contains \Drupal\Core\Config\Importer\FinalMissingContentSubscriber.
*/
namespace Drupal\Core\Config\Importer;
use Drupal\Core\Config\ConfigEvents;
use Symfony\Component\EventDispatcher\EventSubscriberInterface;
/**
* Final event subscriber to the missing content event.
*
* Ensure that all missing content dependencies are removed from the event so
* the importer can complete.
*
* @see \Drupal\Core\Config\ConfigImporter::processMissingContent()
*/
class FinalMissingContentSubscriber implements EventSubscriberInterface {
/**
* Handles the missing content event.
*
* @param \Drupal\Core\Config\Importer\MissingContentEvent $event
* The missing content event.
*/
public function onMissingContent(MissingContentEvent $event) {
foreach (array_keys($event->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;
}
}

View File

@ -0,0 +1,64 @@
<?php
/**
* @file
* Contains \Drupal\Core\Config\MissingContentEvent.
*/
namespace Drupal\Core\Config\Importer;
use Symfony\Component\EventDispatcher\Event;
/**
* Wraps a configuration event for event listeners.
*
* @see \Drupal\Core\Config\Config\ConfigEvents::IMPORT_MISSING_CONTENT
*/
class MissingContentEvent extends Event {
/**
* A list of missing content dependencies.
*
* @var array
*/
protected $missingContent;
/**
* Constructs a configuration import missing content event object.
*
* @param array $missing_content
* Missing content information.
*/
public function __construct(array $missing_content) {
$this->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;
}
}

View File

@ -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 @@ class ConfigDependencyTest extends KernelTestBase {
/**
* 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 @@ class ConfigDependencyTest extends KernelTestBase {
$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 @@ class ConfigDependencyTest extends KernelTestBase {
$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);
}
/**

View File

@ -0,0 +1,107 @@
<?php
/**
* @file
* Contains \Drupal\config\Tests\ConfigImporterMissingContentTest.
*/
namespace Drupal\config\Tests;
use Drupal\Component\Utility\SafeMarkup;
use Drupal\Core\Config\ConfigImporter;
use Drupal\Core\Config\ConfigImporterException;
use Drupal\Core\Config\StorageComparer;
use Drupal\simpletest\KernelTestBase;
/**
* Tests importing configuration which has missing content dependencies.
*
* @group config
*/
class ConfigImporterMissingContentTest extends KernelTestBase {
/**
* Config Importer object used for testing.
*
* @var \Drupal\Core\Config\ConfigImporter
*/
protected $configImporter;
/**
* Modules to enable.
*
* @var array
*/
public static $modules = array('system', 'user', 'entity_test', 'config_test', 'config_import_test');
protected function setUp() {
parent::setUp();
$this->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.
*
* @see \Drupal\Core\Config\ConfigImporter::processMissingContent()
* @see \Drupal\config_import_test\EventSubscriber
*/
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' => 'two'));
$entity_three = entity_create('entity_test', array('name' => 'three'));
$dynamic_name = 'config_test.dynamic.dotted.default';
$original_dynamic_data = $storage->read($dynamic_name);
// Entity one will be resolved by
// \Drupal\config_import_test\EventSubscriber::onConfigImporterMissingContentOne().
$original_dynamic_data['dependencies']['content'][] = $entity_one->getConfigDependencyName();
// Entity two will be resolved by
// \Drupal\config_import_test\EventSubscriber::onConfigImporterMissingContentTwo().
$original_dynamic_data['dependencies']['content'][] = $entity_two->getConfigDependencyName();
// Entity three will be resolved by
// \Drupal\Core\Config\Importer\FinalMissingContentSubscriber.
$original_dynamic_data['dependencies']['content'][] = $entity_three->getConfigDependencyName();
$staging->write($dynamic_name, $original_dynamic_data);
// Import.
$this->configImporter->reset()->import();
$this->assertEqual([], $this->configImporter->getErrors(), 'There were no errors during the 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(), $entity_three->getConfigDependencyName()], $original_dynamic_data['dependencies']['content'], 'The imported configuration entity has the missing content entity dependency.');
}
}

View File

@ -10,6 +10,7 @@ namespace Drupal\config_import_test;
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;
@ -51,6 +52,39 @@ class EventSubscriber implements EventSubscriberInterface {
}
}
/**
* 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) && $this->state->get('config_import_test.config_import_missing_content_two', FALSE) === 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.
*
@ -106,6 +140,7 @@ class EventSubscriber implements EventSubscriberInterface {
$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;
}