diff --git a/core/includes/config.inc b/core/includes/config.inc index 8d0eea1..aa7e3a8 100644 --- a/core/includes/config.inc +++ b/core/includes/config.inc @@ -2,6 +2,13 @@ use Drupal\Core\Config\DatabaseStorage; use Drupal\Core\Config\FileStorage; +use Drupal\Core\Config\ConfigException; + +/** + * The value a module should return from hook_config_sync() to indicate that + * it should be called again by the config system during this sync. + */ +const CONFIG_DEFER_SYNC = 'CONFIG_DEFER_SYNC'; /** * @file @@ -73,16 +80,215 @@ function config_get_storage_names_with_prefix($prefix = '') { * The name of the configuration object to retrieve. The name corresponds to * a configuration file. For @code config(book.admin) @endcode, the config * object returned will contain the contents of book.admin configuration file. - * @param $class - * The class name of the config object to be returned. Defaults to - * DrupalConfig. + * @param $config_class + * (optional) The class name for the configuration object to be returned. + * Defaults to Drupal\Core\Config\DupalConfig. + * @param $storage_class + * (optional) A storage driver class name to use for the configuration class + * object. Defaults to Drupal\Core\Config\DatabaseStorage. * * @return - * An instance of the class specified in the $class parameter. + * An instance of the $config_class class. * - * @todo Replace this with an appropriate factory / ability to inject in - * alternate storage engines.. + * @todo Allow to inject alternate storage engines. */ -function config($name, $class = 'Drupal\Core\Config\DrupalConfig') { - return new $class(new DatabaseStorage($name)); +function config($name, $config_class = NULL, $storage_class = NULL) { + if (!isset($config_class)) { + $config_class = 'Drupal\Core\Config\DrupalConfig'; + } + if (!isset($storage_class)) { + $storage_class = 'Drupal\Core\Config\DatabaseStorage'; + } + return new $config_class(new $storage_class($name)); } + +/** + * Synchronizes configuration from FileStorage to DatabaseStorage. + */ +function config_sync() { + $config_changes = config_get_changes_from_disk(); + if (empty($config_changes)) { + return; + } + + $lock_name = __FUNCTION__; + if (!lock_acquire($lock_name)) { + // Another request is synchronizing configuration. Wait for it to complete, + // then re-check for any remaining differences. + lock_wait($lock_name); + return config_sync(); + } + + try { + config_sync_invoke_sync_hooks($config_changes); + config_sync_save_changes($config_changes); + // Flush all caches and reset static variables after a successful import. + drupal_flush_all_caches(); + } + catch (ConfigException $e) { + watchdog_exception('config_sync', $e); + config_sync_invoke_sync_error_hooks($config_changes); + lock_release($lock_name); + return FALSE; + } + lock_release($lock_name); + return TRUE; +} + +/** + * Writes an array of config file changes to the active store. + * + * @param array $config_changes + * An array of changes to be written. + */ +function config_sync_save_changes(array $config_changes) { + foreach (array('new', 'changed', 'deleted') as $type) { + foreach ($config_changes[$type] as $name) { + if ($type == 'deleted') { + config($name)->delete(); + } + else { + // Get the active store object, set the new data from file, then save. + $target_config = config($name); + $source_config = config($name, NULL, 'Drupal\Core\Config\FileStorage'); + $target_config->setData($source_config->get()); + $target_config->save(); + } + } + } +} + +/** + * Invokes hook_config_sync_validate() and hook_config_sync() implementations. + * + * @param array $config_changes + * An array of changes to be loaded. + */ +function config_sync_invoke_sync_hooks(array $config_changes) { + // Keep a copy of the changes so that modules cannot modify the values by + // taking the array by reference. + $config_changes_copy = $config_changes; + + // @todo Lock writes to ALL config storages to prevent other/unintended config + // changes from happening during the import. + + $target_storage = config(NULL, NULL, 'Drupal\Core\Config\DatabaseStorage'); + $source_storage = config(NULL, NULL, 'Drupal\Core\Config\FileStorage'); + + // Allow all modules to deny configuration changes. + foreach (module_implements('config_sync_validate') as $module) { + $config_changes = $config_changes_copy; + $function = $module . '_config_sync_validate'; + $function($config_changes, $target_storage, $source_storage); + } + + // We allow modules to signal that they would like to be rerun after all + // other modules by returning CONFIG_DEFER_SYNC. Loop until there are no + // modules left that indicate they would like to be re-run, checking that we + // are not stuck re-running the same list of modules infinitely. + // The list of modules is ordered by the reversed chain of module + // dependencies, in order to invoke dependent modules first, and thus decrease + // the possibility for CONFIG_DEFER_SYNC requests and increase the chance of + // being able to execute all changes in a single loop. + $modules = config_sync_sort_dependencies(module_implements('config_sync')); + do { + // Prevent an infinite loop. If the two variables stay the same, then all + // remaining modules asked to defer their import operations, which means + // that there is a unmet dependency. + $initial_module_list = $modules; + + foreach ($modules as $key => $module) { + $config_changes = $config_changes_copy; + $function = $module . '_config_sync'; + if ($function($config_changes, $target_storage, $source_storage) !== CONFIG_DEFER_SYNC) { + unset($modules[$key]); + } + } + } while ($modules && $modules != $initial_module_list); + + // If there are modules left, then we hit an infinite loop. + if ($modules) { + throw new ConfigException('Unmet dependencies detected during synchronization.'); + } +} + +/** + * Invokes hook_config_sync_error() implementations. + * + * During a sync run, modules may make changes that cannot be rolled back. + * This hook allows modules to react to an error that occurs after they have + * made such changes, and make sure that the state of configuration in the + * active store is correct. + * + * @param array $config_changes + * An array of changes to be loaded. + */ +function config_sync_invoke_sync_error_hooks(array $config_changes) { + $target_storage = config(NULL, NULL, 'Drupal\Core\Config\DatabaseStorage'); + $source_storage = config(NULL, NULL, 'Drupal\Core\Config\FileStorage'); + + foreach (module_implements('config_sync_error') as $module) { + $function = $module . '_config_sync_error'; + try { + $function($config_changes, $target_storage, $source_storage); + } + catch (ConfigException $e) { + // Just keep going, because we need to allow all modules to react even if + // some of them are behaving badly. + } + } +} + +/** + * Sorts a given list of modules based on their dependencies. + * + * @param array $modules + * A list of modules. + * + * @return array + * The list of modules sorted by dependency. + */ +function config_sync_sort_dependencies(array $modules) { + // Get all module data so we can find weights and sort. + $module_data = system_rebuild_module_data(); + + $sorted_modules = array(); + foreach ($modules as $module) { + $sorted_modules[$module] = $module_data[$module]->sort; + } + arsort($sorted_modules); + return array_keys($sorted_modules); +} + +/** + * Returns a list of changes on disk compared to the active store. + * + * @return array|bool + * The list of files changed on disk compared to the active store, or FALSE if + * there are no differences. + */ +function config_get_changes_from_disk() { + $disk_config_names = FileStorage::getNamesWithPrefix(); + $active_config_names = DatabaseStorage::getNamesWithPrefix(); + $config_changes = array( + 'new' => array_diff($disk_config_names, $active_config_names), + 'changed' => array(), + 'deleted' => array_diff($active_config_names, $disk_config_names), + ); + foreach (array_intersect($disk_config_names, $active_config_names) as $name) { + $active_config = config($name); + $file_config = config($name, NULL, 'Drupal\Core\Config\FileStorage'); + if ($active_config->get() != $file_config->get()) { + $config_changes['changed'][] = $name; + } + } + + // Do not trigger subsequent synchronization operations if there are no + // changes in either category. + if (empty($config_changes['new']) && empty($config_changes['changed']) && empty($config_changes['deleted'])) { + return FALSE; + } + + return $config_changes; +} + diff --git a/core/includes/module.inc b/core/includes/module.inc index 6b4604a..d916ea6 100644 --- a/core/includes/module.inc +++ b/core/includes/module.inc @@ -484,7 +484,7 @@ function module_enable($module_list, $enable_dependencies = TRUE) { $versions = drupal_get_schema_versions($module); $version = $versions ? max($versions) : SCHEMA_INSTALLED; - // Copy any default configuration data to the system config directory/ + // Install default configuration of the module. config_install_default_config($module); // If the module has no current updates, but has some that were diff --git a/core/lib/Drupal/Core/Config/DrupalConfig.php b/core/lib/Drupal/Core/Config/DrupalConfig.php index f5a9220..c05417e 100644 --- a/core/lib/Drupal/Core/Config/DrupalConfig.php +++ b/core/lib/Drupal/Core/Config/DrupalConfig.php @@ -18,6 +18,13 @@ class DrupalConfig { protected $storage; /** + * The name of the current configuration object. + * + * @var string + */ + protected $name; + + /** * The data of the configuration object. * * @var array @@ -34,7 +41,24 @@ class DrupalConfig { */ public function __construct(StorageInterface $storage) { $this->storage = $storage; + // Retrieve the configuration object name assigned to the storage + // controller and automatically load it, if any. + $this->name = $this->storage->getName(); + if (isset($this->name)) { + $this->read(); + } + } + + /** + * Loads a configuration object. + * + * @param string $name + * The configuration object name to load. + */ + public function load($name) { + $this->storage->setName($name); $this->read(); + return $this; } /** @@ -201,6 +225,7 @@ class DrupalConfig { else { drupal_array_unset_nested_value($this->data, $parts); } + return $this; } /** @@ -208,6 +233,7 @@ class DrupalConfig { */ public function save() { $this->storage->write($this->data); + return $this; } /** @@ -216,5 +242,6 @@ class DrupalConfig { public function delete() { $this->data = array(); $this->storage->delete(); + return $this; } } diff --git a/core/lib/Drupal/Core/Config/FileStorage.php b/core/lib/Drupal/Core/Config/FileStorage.php index 5e2ac1e..f956eb5 100644 --- a/core/lib/Drupal/Core/Config/FileStorage.php +++ b/core/lib/Drupal/Core/Config/FileStorage.php @@ -2,15 +2,15 @@ namespace Drupal\Core\Config; +use Drupal\Core\Config\StorageInterface; use Symfony\Component\Yaml\Yaml; /** * Represents the file storage controller. * - * @todo Implement StorageInterface after removing DrupalConfig methods. * @todo Consider to extend StorageBase. */ -class FileStorage { +class FileStorage implements StorageInterface { /** * The name of the configuration object. @@ -82,7 +82,7 @@ class FileStorage { * @return bool * TRUE if the configuration file exists, FALSE otherwise. */ - protected function exists() { + public function exists() { return file_exists($this->getFilePath()); } @@ -96,6 +96,7 @@ class FileStorage { if (!file_put_contents($this->getFilePath(), $data)) { throw new FileStorageException('Failed to write configuration file: ' . $this->getFilePath()); } + return $this; } /** @@ -120,8 +121,8 @@ class FileStorage { * Deletes a configuration file. */ public function delete() { - // Needs error handling and etc. - @drupal_unlink($this->getFilePath()); + // @todo Error handling. + return @drupal_unlink($this->getFilePath()); } /** @@ -169,4 +170,41 @@ class FileStorage { }; return array_map($clean_name, $files); } + + /** + * Implements StorageInterface::copyToFile(). + */ + public function copyToFile() { + // @todo Untangle StorageInterface. + } + + /** + * Implements StorageInterface::copyFromFile(). + */ + public function copyFromFile() { + // @todo Untangle StorageInterface. + } + + /** + * Implements StorageInterface::deleteFile(). + */ + public function deleteFile() { + // @todo Untangle StorageInterface. + return $this->delete(); + } + + /** + * Implements StorageInterface::writeToActive(). + */ + public function writeToActive($data) { + // @todo Untangle StorageInterface. + } + + /** + * Implements StorageInterface::writeToFile(). + */ + public function writeToFile($data) { + // @todo Untangle StorageInterface. + return $this->write($data); + } } diff --git a/core/lib/Drupal/Core/Config/StorageBase.php b/core/lib/Drupal/Core/Config/StorageBase.php index b03ff27..f521332 100644 --- a/core/lib/Drupal/Core/Config/StorageBase.php +++ b/core/lib/Drupal/Core/Config/StorageBase.php @@ -76,13 +76,6 @@ abstract class StorageBase implements StorageInterface { } /** - * Implements StorageInterface::isOutOfSync(). - */ - public function isOutOfSync() { - return $this->read() !== $this->readFromFile(); - } - - /** * Implements StorageInterface::write(). */ public function write($data) { diff --git a/core/lib/Drupal/Core/Config/StorageInterface.php b/core/lib/Drupal/Core/Config/StorageInterface.php index 43141a5..103b382 100644 --- a/core/lib/Drupal/Core/Config/StorageInterface.php +++ b/core/lib/Drupal/Core/Config/StorageInterface.php @@ -41,15 +41,6 @@ interface StorageInterface { function deleteFile(); /** - * Checks whether the file and the storage is in sync. - * - * @return - * TRUE if the file and the storage contains the same data, FALSE - * if not. - */ - function isOutOfSync(); - - /** * Writes the configuration data into the active storage and the file. * * @param $data diff --git a/core/modules/config/config.admin.inc b/core/modules/config/config.admin.inc new file mode 100644 index 0000000..1169bc0 --- /dev/null +++ b/core/modules/config/config.admin.inc @@ -0,0 +1,58 @@ + 'There are no changes on disk to reload.' + ); + return $form; + } + + foreach ($config_changes as $config_change_type => $config_files) { + if (empty($config_files)) { + continue; + } + $form[$config_change_type] = array( + '#type' => 'fieldset', + '#title' => $config_change_type . ' (' . count($config_files) . ')', + '#collapsible' => TRUE, + ); + $form[$config_change_type]['config_files'] = array( + '#theme' => 'table', + '#header' => array('Name'), + ); + foreach ($config_files as $config_file) { + $form[$config_change_type]['config_files']['#rows'][] = array($config_file); + } + } + $form['submit'] = array( + '#type' => 'submit', + '#value' => t('Import'), + ); + return $form; +} + +/** + * Form submission handler for config_admin_import_form(). + */ +function config_admin_import_form_submit($form, &$form_state) { + if (config_sync()) { + drupal_set_message('Configuration successfully reloaded from disk.'); + } + else { + drupal_set_message('There was an error reloading configuration from disk.', 'error'); + } +} + diff --git a/core/modules/config/config.api.php b/core/modules/config/config.api.php new file mode 100644 index 0000000..e645c39 --- /dev/null +++ b/core/modules/config/config.api.php @@ -0,0 +1,125 @@ +load() to load a + * configuration object. + * @param $source_storage + * A configuration class acting on the source storage from which configuration + * differences were read. Use $target_storage->load() to load a configuration + * object. + * + * @throws ConfigException + * In case a configuration change cannot be allowed. + */ +function hook_config_sync_validate($config_changes, $target_storage, $source_storage) { + // Deny changes to our settings. + if (isset($config_changes['changed']['mymodule.locked'])) { + throw new ConfigException('MyModule settings cannot be changed.'); + } +} + +/** + * Synchronize configuration changes. + * + * This hook is invoked when configuration is synchronized between storages and + * allows all modules to react to new, deleted, and changed configuration. This + * hook is invoked before the new configuration is written to the target storage. + * Implementations of this hook only react to the differences. The new + * configuration itself is written by the configuration system. + * + * @param array $config_changes + * An associative array whose keys denote the configuration differences + * ('new', 'changed', 'deleted') and whose values are arrays of configuration + * object names. + * @param $target_storage + * A configuration class acting on the target storage to which the new + * configuration will be written. Use $target_storage->load() to load a + * configuration object. + * @param $source_storage + * A configuration class acting on the source storage from which configuration + * differences were read. Use $target_storage->load() to load a configuration + * object. + * + * @return + * Nothing on successful synchronization, or CONFIG_DEFER_SYNC if a change + * cannot be synchronized yet adn depends on another module to execute first. + */ +function hook_config_sync($config_changes, $target_storage, $source_storage) { + foreach ($config_changes['new'] as $name) { + if (strpos($name, 'image.style.') === 0) { + // Load the new configuration data and inform other modules about it. + $style = $source_storage->load($name)->get(); + $style['is_new'] = TRUE; + module_invoke_all('image_style_save', $style); + } + } + foreach ($config_changes['changed'] as $name) { + if (strpos($name, 'image.style.') === 0) { + // Load the new configuration data, inform other modules about the change, + // and perform any further actions that may be required for the change to + // take effect. + $style = $source_storage->load($name)->get(); + $style['is_new'] = FALSE; + module_invoke_all('image_style_save', $style); + // Delete existing derivative images. + image_style_flush($style); + } + } + foreach ($config_changes['deleted'] as $name) { + if (strpos($name, 'image.style.') === 0) { + // The style has been deleted, so read the previous configuration from the + // old storage. + $style = $target_storage->load($name)->get(); + module_invoke_all('image_style_delete', $style); + // Delete existing derivative images. + image_style_flush($style); + } + } +} + +/** + * Validate configuration changes before they are saved to the active store. + * + * During synchronization of configuration, modules may make changes that cannot + * be rolled back. This hook allows modules to react to an error that occurs + * after they have made such changes, and make sure that the state of + * configuration is as correct as possible. + * + * @param array $config_changes + * An associative array whose keys denote the configuration differences + * ('new', 'changed', 'deleted') and whose values are arrays of configuration + * object names. + * @param $target_storage + * A configuration class acting on the target storage to which the new + * configuration will be written. Use $target_storage->load() to load a + * configuration object. + * @param $source_storage + * A configuration class acting on the source storage from which configuration + * differences were read. Use $target_storage->load() to load a configuration + * object. + */ +function hook_config_sync_error($config_changes, $target_storage, $source_storage) { + // @todo Feasability and usage of this hook is still unclear, without having a + // backup of $target_storage at hand. +} + diff --git a/core/modules/config/config.module b/core/modules/config/config.module index b3d9bbc..56c3bb8 100644 --- a/core/modules/config/config.module +++ b/core/modules/config/config.module @@ -1 +1,34 @@ t('Import configuration'), + 'restrict access' => TRUE, + ); + return $permissions; +} + +/** + * Implements hook_menu(). + */ +function config_menu() { + $items['admin/config/development/import'] = array( + 'title' => 'Import configuration', + 'description' => 'Import and synchronize configuration changes.', + 'page callback' => 'drupal_get_form', + 'page arguments' => array('config_admin_import_form'), + 'access arguments' => array('import configuration'), + 'file' => 'config.admin.inc', + ); + return $items; +} + diff --git a/core/modules/config/config.test b/core/modules/config/config.test index 257ab0f..bf18819 100644 --- a/core/modules/config/config.test +++ b/core/modules/config/config.test @@ -286,6 +286,144 @@ class ConfigFileContentTestCase extends WebTestBase { } } +/** + * Tests config_sync() functionality. + */ +class ConfigImportTestCase extends WebTestBase { + public static function getInfo() { + return array( + 'name' => 'Import configuration', + 'description' => 'Tests importing configuration from files into active store.', + 'group' => 'Configuration', + ); + } + + function setUp() { + parent::setUp('config_test'); + $this->fileExtension = FileStorage::getFileExtension(); + } + + /** + * Tests deletion of configuration during import. + */ + function testDeleted() { + $name = 'config_test.system'; + + // Verify the default configuration value exists. + $config = config($name); + $this->assertIdentical($config->get('foo'), 'bar'); + + // Delete the configuration object. + $file = new FileStorage($name); + $file->delete(); + + // Import. + config_sync(); + + // Verify the value has disappeared. + $config = config($name); + $this->assertIdentical($config->get('foo'), NULL); + } + + /** + * Tests creation of configuration during import. + */ + function testNew() { + $name = 'config_test.new'; + + // Verify the configuration to create does not exist yet. + $file = new FileStorage($name); + $this->assertIdentical($file->exists(), FALSE, $name . ' not found.'); + + // Create a new configuration object. + $file->write(array( + 'add_me' => 'new value', + )); + $this->assertIdentical($file->exists(), TRUE, $name . ' found.'); + + // Import. + config_sync(); + + // Verify the value has appeared. + $config = config($name); + $this->assertIdentical($config->get('add_me'), 'new value'); + } + + /** + * Tests updating of configuration during import. + */ + function testUpdated() { + $name = 'config_test.system'; + + // Replace the file content of the existing configuration object. + $file = new FileStorage($name); + $this->assertIdentical($file->exists(), TRUE, $name . ' found.'); + $file->write(array( + 'foo' => 'beer', + )); + + // Verify the active store still returns the default value. + $config = config($name); + $this->assertIdentical($config->get('foo'), 'bar'); + + // Import. + config_sync(); + + // Verify the value was updated. + $config = config($name); + $this->assertIdentical($config->get('foo'), 'beer'); + } + + /** + * Tests config_sync() hook invocations. + */ + function testSyncHooks() { + $name = 'config_test.system'; + + // Delete a file so that hook_config_sync() hooks are run. + $file = new FileStorage($name); + $this->assertIdentical($file->exists(), TRUE, $name . ' found.'); + $file->delete(); + + // Make the test implementation throw an error during synchronization, so + // hook_config_sync_error() is also invoked. + $GLOBALS['config_sync_throw_error'] = TRUE; + + // Import. + config_sync(); + + // Verify hook_config_sync() was invoked. + $this->assertIdentical($GLOBALS['hook_config_sync'], 'config_test_config_sync'); + // Verify hook_config_sync_error() was invoked. + $this->assertIdentical($GLOBALS['hook_config_sync_validate'], 'config_test_config_sync_validate'); + // Verify hook_config_sync_error() was invoked. + $this->assertIdentical($GLOBALS['hook_config_sync_error'], 'config_test_config_sync_error'); + } + + /** + * Tests abort of import upon validation error. + */ + function testSyncValidationError() { + $name = 'config_test.system'; + + // Delete a file so that hook_config_sync() hooks are run. + $file = new FileStorage($name); + $this->assertIdentical($file->exists(), TRUE, $name . ' found.'); + $file->delete(); + + // Make the test implementation throw a validation error, aborting the + // synchronization. + $GLOBALS['config_sync_validate_throw_error'] = TRUE; + + // Import. + config_sync(); + + // Verify the active store was not updated. + $config = config($name); + $this->assertIdentical($config->get('foo'), 'bar'); + } +} + /** * Tests configuration overriding from settings.php. */ diff --git a/core/modules/config/config_test/config/config_test.delete.yml b/core/modules/config/config_test/config/config_test.delete.yml new file mode 100644 index 0000000..b8ccb67 --- /dev/null +++ b/core/modules/config/config_test/config/config_test.delete.yml @@ -0,0 +1 @@ +delete_me: bar diff --git a/core/modules/config/config_test/config/config_test.system.yml b/core/modules/config/config_test/config/config_test.system.yml new file mode 100644 index 0000000..20e9ff3 --- /dev/null +++ b/core/modules/config/config_test/config/config_test.system.yml @@ -0,0 +1 @@ +foo: bar diff --git a/core/modules/config/config_test/config_test.info b/core/modules/config/config_test/config_test.info new file mode 100644 index 0000000..8735450 --- /dev/null +++ b/core/modules/config/config_test/config_test.info @@ -0,0 +1,6 @@ +name = Configuration test module +package = Core +version = VERSION +core = 8.x +dependencies[] = config +hidden = TRUE diff --git a/core/modules/config/config_test/config_test.module b/core/modules/config/config_test/config_test.module new file mode 100644 index 0000000..88abf7c --- /dev/null +++ b/core/modules/config/config_test/config_test.module @@ -0,0 +1,36 @@ +load($file_name)->get(); + $style['is_new'] = TRUE; + module_invoke_all('image_style_save', $style); + image_style_flush($style); + } + } + foreach ($config_changes['changed'] as $file_name) { + if (strpos($file_name, 'image.style.') === 0) { + $style = $source_storage->load($file_name)->get(); + $style['is_new'] = FALSE; + module_invoke_all('image_style_save', $style); + image_style_flush($style); + } + } + foreach ($config_changes['deleted'] as $file_name) { + if (strpos($file_name, 'image.style.') === 0) { + // The style has been deleted, so read the previous configuration from the + // old storage. + $style = $target_storage->load($file_name)->get(); + image_style_flush($style); + + // @todo image_style_delete() supports the notion of a "replacement style" + // to be used by other modules instead of the deleted style. Good idea. + // But squeezing that into a "delete" operation is the worst idea ever. + // Regardless of Image module insanity, add a 'replaced' stack to + // config_sync()? And how can that work? If an 'old_ID' key would be a + // standard, wouldn't this belong into 'changed' instead? + $style['old_name'] = $style['name']; + $style['name'] = ''; + module_invoke_all('image_style_delete', $style); + } + } +} + +/** * Get an array of all styles and their settings. * * @return