diff --git a/core/includes/config.inc b/core/includes/config.inc index c6435b3..16586c6 100644 --- a/core/includes/config.inc +++ b/core/includes/config.inc @@ -1,7 +1,11 @@ $module_config_dir)); + $source_storage = new FileStorage(array('directory' => $module_config_dir)); + $target_storage = new DatabaseStorage(); + $null_storage = new NullStorage(); - foreach ($module_file_storage->listAll() as $config_name) { - $data = $module_file_storage->read($config_name); - $database_storage->write($config_name, $data); + // Upon module installation, only new config objects need to be created. + // config_sync_get_changes() would potentially perform a diff of hundreds or + // even thousands of config objects that happen to be contained in the + // active store. We leverage the NullStorage to avoid that needless + // computation of differences. + $config_changes = config_sync_get_changes($source_storage, $null_storage); + if (empty($config_changes)) { + return; } + $remaining_changes = config_import_invoke_owner($config_changes, $source_storage, $target_storage); + config_sync_changes($remaining_changes, $source_storage, $target_storage); } } @@ -57,3 +69,177 @@ function config($name) { return drupal_container()->get('config.factory')->get($name)->load(); } +/** + * Returns a list of differences between configuration storages. + * + * @param Drupal\Core\Config\StorageInterface $source_storage + * The storage to synchronize configuration from. + * @param Drupal\Core\Config\StorageInterface $target_storage + * The storage to synchronize configuration to. + * + * @return array|bool + * An assocative array containing the differences between source and target + * storage, or FALSE if there are no differences. + */ +function config_sync_get_changes(StorageInterface $source_storage, StorageInterface $target_storage) { + $source_names = $source_storage->listAll(); + $target_names = $target_storage->listAll(); + $config_changes = array( + 'create' => array_diff($source_names, $target_names), + 'change' => array(), + 'delete' => array_diff($target_names, $source_names), + ); + foreach (array_intersect($source_names, $target_names) as $name) { + $source_config_data = $source_storage->read($name); + $target_config_data = $target_storage->read($name); + if ($source_config_data != $target_config_data) { + $config_changes['change'][] = $name; + } + } + + // Do not trigger subsequent synchronization operations if there are no + // changes in either category. + if (empty($config_changes['create']) && empty($config_changes['change']) && empty($config_changes['delete'])) { + return FALSE; + } + return $config_changes; +} + +/** + * Writes an array of config file changes from a source storage to a target storage. + * + * @param array $config_changes + * An array of changes to be written. + * @param Drupal\Core\Config\StorageInterface $source_storage + * The storage to synchronize configuration from. + * @param Drupal\Core\Config\StorageInterface $target_storage + * The storage to synchronize configuration to. + */ +function config_sync_changes(array $config_changes, StorageInterface $source_storage, StorageInterface $target_storage) { + foreach (array('delete', 'create', 'change') as $op) { + foreach ($config_changes[$op] as $name) { + if ($op == 'delete') { + $target_storage->delete($name); + } + else { + $data = $source_storage->read($name); + $target_storage->write($name, $data); + } + } + } +} + +/** + * Imports configuration from FileStorage to DatabaseStorage. + */ +function config_import() { + // Retrieve a list of differences between FileStorage and DatabaseStorage. + // @todo Leverage DI + config.storage.info. + $source_storage = new FileStorage(); + $target_storage = new DatabaseStorage(); + + $config_changes = config_sync_get_changes($source_storage, $target_storage); + if (empty($config_changes)) { + return; + } + + if (!lock_acquire(__FUNCTION__)) { + // Another request is synchronizing configuration. + // Return a negative result for UI purposes. We do not make a difference + // between an actual synchronization error and a failed lock, because a + // concurrent request synchronizing configuration is an edge-case in the + // first place and would mean that more than one developer or site builder + // attempts to do it without coordinating with others. + return FALSE; + } + + try { + $remaining_changes = config_import_invoke_owner($config_changes, $source_storage, $target_storage); + config_sync_changes($remaining_changes, $source_storage, $target_storage); + // Flush all caches and reset static variables after a successful import. + drupal_flush_all_caches(); + } + catch (ConfigException $e) { + watchdog_exception('config_import', $e); + lock_release(__FUNCTION__); + return FALSE; + } + lock_release(__FUNCTION__); + return TRUE; +} + +/** + * Invokes MODULE_config_import() callbacks for configuration changes. + * + * @param array $config_changes + * An array of changes to be loaded. + * @param Drupal\Core\Config\StorageInterface $source_storage + * The storage to synchronize configuration from. + * @param Drupal\Core\Config\StorageInterface $target_storage + * The storage to synchronize configuration to. + * + * @todo Add support for other extension types; e.g., themes etc. + */ +function config_import_invoke_owner(array $config_changes, StorageInterface $source_storage, StorageInterface $target_storage) { + $storage_dispatcher = drupal_container()->get('config.storage.dispatcher'); + + // Allow modules to take over configuration change operations for + // higher-level configuration data. + // First pass deleted, then new, and lastly changed configuration, in order to + // handle dependencies correctly. + foreach (array('delete', 'create', 'change') as $op) { + foreach ($config_changes[$op] as $key => $name) { + // Extract owner from configuration object name. + $module = strtok($name, '.'); + // Check whether the module implements hook_config_import() and ask it to + // handle the configuration change. + $handled_by_module = FALSE; + if (module_hook($module, 'config_import_' . $op)) { + $old_config = new Config($storage_dispatcher); + $old_config + ->setName($name) + ->load(); + // If no data exists in the target storage for the delete operation, + // then this change was manually specified and can be skipped. + if ($op == 'delete' && $old_config->isNew()) { + continue; + } + + $data = $source_storage->read($name); + // If no data exists in the source storage for the create operation, + // then this change was manually specified and can be skipped. + if ($op == 'create' && $data === FALSE) { + continue; + } + $new_config = new Config($storage_dispatcher); + $new_config->setName($name); + if ($data !== FALSE) { + $new_config->setData($data); + } + + $handled_by_module = module_invoke($module, 'config_import_' . $op, $name, $new_config, $old_config); + } + if (!empty($handled_by_module)) { + unset($config_changes[$op][$key]); + } + } + } + return $config_changes; +} + +/** + * Exports configuration from DatabaseStorage to FileStorage. + */ +function config_export() { + // Retrieve a list of differences between DatabaseStorage and FileStorage. + // @todo Leverage DI + config.storage.info. + $source_storage = new DatabaseStorage(); + $target_storage = new FileStorage(); + + $config_changes = config_sync_get_changes($source_storage, $target_storage); + if (empty($config_changes)) { + return; + } + config_sync_changes($config_changes, $source_storage, $target_storage); + return TRUE; +} diff --git a/core/includes/form.inc b/core/includes/form.inc index 571a439..9635106 100644 --- a/core/includes/form.inc +++ b/core/includes/form.inc @@ -3532,6 +3532,7 @@ function form_process_machine_name($element, &$form_state) { // 'source' only) would leave all other properties undefined, if the defaults // were defined in hook_element_info(). Therefore, we apply the defaults here. $element['#machine_name'] += array( + // @todo Use 'label' by default. 'source' => array('name'), 'target' => '#' . $element['#id'], 'label' => t('Machine name'), diff --git a/core/lib/Drupal/Core/Config/Config.php b/core/lib/Drupal/Core/Config/Config.php index 0748b76..ea14595 100644 --- a/core/lib/Drupal/Core/Config/Config.php +++ b/core/lib/Drupal/Core/Config/Config.php @@ -20,6 +20,13 @@ class Config { protected $name; /** + * Whether the configuration object is new or has been saved to the storage. + * + * @var bool + */ + protected $isNew = TRUE; + + /** * The data of the configuration object. * * @var array @@ -60,6 +67,13 @@ class Config { } /** + * Returns whether this configuration object is new. + */ + public function isNew() { + return $this->isNew; + } + + /** * Gets data from this config object. * * @param $key @@ -208,9 +222,13 @@ class Config { * Loads configuration data into this object. */ public function load() { - $this->setData(array()); $data = $this->storageDispatcher->selectStorage('read', $this->name)->read($this->name); - if ($data !== FALSE) { + if ($data === FALSE) { + $this->isNew = TRUE; + $this->setData(array()); + } + else { + $this->isNew = FALSE; $this->setData($data); } return $this; @@ -222,6 +240,7 @@ class Config { public function save() { $this->sortByKey($this->data); $this->storageDispatcher->selectStorage('write', $this->name)->write($this->name, $this->data); + $this->isNew = FALSE; return $this; } @@ -249,8 +268,10 @@ class Config { * Deletes the configuration object. */ public function delete() { + // @todo Consider to remove the pruning of data for Config::delete(). $this->data = array(); $this->storageDispatcher->selectStorage('write', $this->name)->delete($this->name); + $this->isNew = TRUE; return $this; } } diff --git a/core/lib/Drupal/Core/Config/DatabaseStorage.php b/core/lib/Drupal/Core/Config/DatabaseStorage.php index 1dd8507..f30011c 100644 --- a/core/lib/Drupal/Core/Config/DatabaseStorage.php +++ b/core/lib/Drupal/Core/Config/DatabaseStorage.php @@ -51,7 +51,7 @@ class DatabaseStorage implements StorageInterface { * Only thrown in case $this->options['throw_exception'] is TRUE. */ public function read($name) { - $data = array(); + $data = FALSE; // There are situations, like in the installer, where we may attempt a // read without actually having the database available. In this case, // catch the exception and just return an empty array so the caller can @@ -114,7 +114,7 @@ class DatabaseStorage implements StorageInterface { */ public static function decode($raw) { $data = @unserialize($raw); - return $data !== FALSE ? $data : array(); + return is_array($data) ? $data : FALSE; } /** diff --git a/core/lib/Drupal/Core/Config/FileStorage.php b/core/lib/Drupal/Core/Config/FileStorage.php index 7827dc0..033555d 100644 --- a/core/lib/Drupal/Core/Config/FileStorage.php +++ b/core/lib/Drupal/Core/Config/FileStorage.php @@ -70,19 +70,12 @@ class FileStorage implements StorageInterface { */ public function read($name) { if (!$this->exists($name)) { - return array(); + return FALSE; } $data = file_get_contents($this->getFilePath($name)); // @todo Yaml throws a ParseException on invalid data. Is it expected to be // caught or not? $data = $this->decode($data); - if ($data === FALSE) { - return array(); - } - // A simple string is valid YAML for any reason. - if (!is_array($data)) { - return array(); - } return $data; } @@ -131,10 +124,12 @@ class FileStorage implements StorageInterface { * @throws Symfony\Component\Yaml\Exception\ParseException */ public static function decode($raw) { - if (empty($raw)) { - return array(); + $data = Yaml::parse($raw); + // A simple string is valid YAML for any reason. + if (!is_array($data)) { + return FALSE; } - return Yaml::parse($raw); + return $data; } /** diff --git a/core/lib/Drupal/Core/Config/NullStorage.php b/core/lib/Drupal/Core/Config/NullStorage.php new file mode 100644 index 0000000..fede4f0 --- /dev/null +++ b/core/lib/Drupal/Core/Config/NullStorage.php @@ -0,0 +1,72 @@ +setConfig($config); + } + + /** + * Implements Drupal\Core\ConfigThingie\ConfigThingieInterface::getConfigPrefix(). + */ + public function getConfigPrefix() { + return 'config_test.dynamic'; + } + + /** + * Implements Drupal\Core\ConfigThingie\ConfigThingieInterface::getConfigName(). + */ + public function getConfigName() { + return $this->getConfigPrefix() . '.' . $this->getID(); + } + + /** + * Implements Drupal\Core\ConfigThingie\ConfigThingieInterface::getEventBasename(). + */ + public function getEventBasename() { + return str_replace('.', '_', $this->getConfigPrefix()); + } + + /** + * Implements Drupal\Core\ConfigThingie\ConfigThingieInterface::getID(). + */ + public function getID() { + return $this->config->get($this->idKey); + } + + /** + * Implements Drupal\Core\ConfigThingie\ConfigThingieInterface::isNew(). + */ + public function isNew() { + return $this->config->isNew(); + } + + /** + * Implements Drupal\Core\ConfigThingie\ConfigThingieInterface::getLabel(). + */ + public function getLabel() { + return $this->config->get($this->labelKey); + } + + /** + * Implements Drupal\Core\ConfigThingie\ConfigThingieInterface::get(). + */ + public function get($property_name) { + return $this->config->get($property_name); + } + + /** + * Implements Drupal\Core\ConfigThingie\ConfigThingieInterface::set(). + */ + public function set($property_name, $value) { + return $this->config->set($property_name, $value); + } + + /** + * Implements Drupal\Core\ConfigThingie\ConfigThingieInterface::setConfig(). + */ + public function setConfig(Config $config) { + $this->config = $config; + // Only set originalID, if the passed in configuration object is stored + // already. + if (!$this->config->isNew()) { + $this->originalID = $this->getID(); + } + + // Allow modules to react upon load. + module_invoke_all($this->getEventBasename() . '_load', $this); + + return $this; + } + + /** + * Implements Drupal\Core\ConfigThingie\ConfigThingieInterface::setOriginal(). + */ + public function setOriginal(Config $config) { + $this->original = new $this($config); + // Ensure that originalID contains the ID of the supplied original + // configuration object. setOriginal() may be called from outside of this + // class (e.g., hook_config_import()) in order to set a specific original. + $this->originalID = $this->original->getID(); + return $this; + } + + /** + * Implements Drupal\Core\ConfigThingie\ConfigThingieInterface::save(). + */ + public function save() { + // Provide the original configuration thingie in $this->original, if any. + // originalID is only set, if this configuration thingie already existed + // prior to saving. + if (isset($this->originalID)) { + // Load the original configuration object. + // This cannot use ConfigThingieBase::getConfigName(), since that would + // yield the name using the current/new ID. + $original_config = config($this->getConfigPrefix() . '.' . $this->originalID); + // Given the original configuration object, instantiate a new class of the + // current class, and provide it in $this->original. + $this->setOriginal($original_config); + } + + // Ensure that the configuration object name uses the current ID. + $this->config->setName($this->getConfigName()); + + // Allow modules to react prior to saving. + module_invoke_all($this->getEventBasename() . '_presave', $this); + + // Save the configuration object. + $this->config->save(); + + if (isset($this->originalID)) { + // Allow modules to react after inserting new configuration. + module_invoke_all($this->getEventBasename() . '_update', $this); + + // Delete the original configuration, if it was renamed. + if ($this->originalID !== $this->getID()) { + // Configuration data is emptied out upon delete, so back it up and + // re-inject it. Delete the old configuration data directly; hooks will + // get and will be able to react to the data in $this->original. + // @todo Consider to remove the pruning of data for Config::delete(). + $original_data = $original_config->get(); + $original_config->delete(); + $original_config->setData($original_data); + } + } + else { + // Allow modules to react after updating existing configuration. + module_invoke_all($this->getEventBasename() . '_insert', $this); + } + + return $this; + } + + /** + * Implements Drupal\Core\ConfigThingie\ConfigThingieInterface::delete(). + */ + public function delete() { + // Allow modules to react prior to deleting the configuration. + module_invoke_all($this->getEventBasename() . '_predelete', $this); + + // Delete the configuration object. + $this->config->delete(); + + // Allow modules to react after deleting the configuration. + module_invoke_all($this->getEventBasename() . '_delete', $this); + } +} diff --git a/core/lib/Drupal/Core/ConfigThingie/ConfigThingieInterface.php b/core/lib/Drupal/Core/ConfigThingie/ConfigThingieInterface.php new file mode 100644 index 0000000..0632ede --- /dev/null +++ b/core/lib/Drupal/Core/ConfigThingie/ConfigThingieInterface.php @@ -0,0 +1,147 @@ + t('There are no configuration changes.'), + ); + 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_import()) { + drupal_set_message(t('The configuration was imported successfully.')); + } + else { + // Another request may be synchronizing configuration already. Wait for it + // to complete before returning the error, so already synchronized changes + // do not appear again. + lock_wait(__FUNCTION__); + drupal_set_message(t('The import failed due to an error. Any errors have been logged.'), 'error'); + } +} + diff --git a/core/modules/config/config.api.php b/core/modules/config/config.api.php new file mode 100644 index 0000000..f0c3afa --- /dev/null +++ b/core/modules/config/config.api.php @@ -0,0 +1,107 @@ +save(); + return TRUE; +} + +/** + * Update configuration upon synchronizing configuration changes. + * + * This callback is invoked when configuration is synchronized between storages + * and allows a module to take over the synchronization of configuration data. + * + * Modules should implement this callback if they manage configuration data + * (such as image styles, node types, or fields) which needs to be + * prepared and passed through module API functions to properly handle a + * configuration change. + * + * @param string $name + * The name of the configuration object. + * @param Drupal\Core\Config\Config $new_config + * A configuration object containing the new configuration data. + * @param Drupal\Core\Config\Config $old_config + * A configuration object containing the old configuration data. + */ +function MODULE_config_import_change($name, $new_config, $old_config) { + // Only configurable thingies require custom handling. Any other module + // settings can be synchronized directly. + if (strpos($name, 'config_test.dynamic.') !== 0) { + return FALSE; + } + $config_test = new ConfigTest($new_config); + $config_test->setOriginal($old_config); + $config_test->save(); + return TRUE; +} + +/** + * Delete configuration upon synchronizing configuration changes. + * + * This callback is invoked when configuration is synchronized between storages + * and allows a module to take over the synchronization of configuration data. + * + * Modules should implement this callback if they manage configuration data + * (such as image styles, node types, or fields) which needs to be + * prepared and passed through module API functions to properly handle a + * configuration change. + * + * @param string $name + * The name of the configuration object. + * @param Drupal\Core\Config\Config $new_config + * A configuration object containing the new configuration data. + * @param Drupal\Core\Config\Config $old_config + * A configuration object containing the old configuration data. + */ +function MODULE_config_import_delete($name, $new_config, $old_config) { + // Only configurable thingies require custom handling. Any other module + // settings can be synchronized directly. + if (strpos($name, 'config_test.dynamic.') !== 0) { + return FALSE; + } + // @todo image_style_delete() supports the notion of a "replacement style" + // to be used by other modules instead of the deleted style. Essential! + // But that is impossible currently, since the config system only knows + // about deleted and added changes. Introduce an 'old_ID' key within + // config objects as a standard? + $config_test = new ConfigTest($old_config); + $config_test->delete(); + return TRUE; +} + diff --git a/core/modules/config/config.module b/core/modules/config/config.module index b3d9bbc..3d4fcfe 100644 --- a/core/modules/config/config.module +++ b/core/modules/config/config.module @@ -1 +1,33 @@ 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/lib/Drupal/config/Tests/ConfigCRUDTest.php b/core/modules/config/lib/Drupal/config/Tests/ConfigCRUDTest.php index aeca024..37aa854 100644 --- a/core/modules/config/lib/Drupal/config/Tests/ConfigCRUDTest.php +++ b/core/modules/config/lib/Drupal/config/Tests/ConfigCRUDTest.php @@ -29,10 +29,13 @@ class ConfigCRUDTest extends WebTestBase { $storage = new DatabaseStorage(); $name = 'config_test.crud'; - // Create a new configuration object. $config = config($name); + $this->assertIdentical($config->isNew(), TRUE); + + // Create a new configuration object. $config->set('value', 'initial'); $config->save(); + $this->assertIdentical($config->isNew(), FALSE); // Verify the active store contains the saved value. $actual_data = $storage->read($name); @@ -41,6 +44,7 @@ class ConfigCRUDTest extends WebTestBase { // Update the configuration object instance. $config->set('value', 'instance-update'); $config->save(); + $this->assertIdentical($config->isNew(), FALSE); // Verify the active store contains the updated value. $actual_data = $storage->read($name); @@ -49,24 +53,28 @@ class ConfigCRUDTest extends WebTestBase { // Verify a call to config() immediately returns the updated value. $new_config = config($name); $this->assertIdentical($new_config->get(), $config->get()); + $this->assertIdentical($config->isNew(), FALSE); // Delete the configuration object. $config->delete(); // Verify the configuration object is empty. $this->assertIdentical($config->get(), array()); + $this->assertIdentical($config->isNew(), TRUE); // Verify the active store contains no value. $actual_data = $storage->read($name); - $this->assertIdentical($actual_data, array()); + $this->assertIdentical($actual_data, FALSE); // Verify config() returns no data. $new_config = config($name); $this->assertIdentical($new_config->get(), $config->get()); + $this->assertIdentical($config->isNew(), TRUE); // Re-create the configuration object. $config->set('value', 're-created'); $config->save(); + $this->assertIdentical($config->isNew(), FALSE); // Verify the active store contains the updated value. $actual_data = $storage->read($name); @@ -75,6 +83,7 @@ class ConfigCRUDTest extends WebTestBase { // Verify a call to config() immediately returns the updated value. $new_config = config($name); $this->assertIdentical($new_config->get(), $config->get()); + $this->assertIdentical($config->isNew(), FALSE); } /** diff --git a/core/modules/config/lib/Drupal/config/Tests/ConfigConfigurableTest.php b/core/modules/config/lib/Drupal/config/Tests/ConfigConfigurableTest.php new file mode 100644 index 0000000..cfb4bcc --- /dev/null +++ b/core/modules/config/lib/Drupal/config/Tests/ConfigConfigurableTest.php @@ -0,0 +1,80 @@ + 'Configurable configuration', + 'description' => 'Tests configurable configuration.', + 'group' => 'Configuration', + ); + } + + function setUp() { + parent::setUp(array('config_test')); + } + + /** + * Tests basic CRUD operations through the UI. + */ + function testCRUD() { + // Create a thingie. + $id = 'thingie'; + $edit = array( + 'id' => $id, + 'label' => 'Thingie', + ); + $this->drupalPost('admin/structure/config_test/add', $edit, 'Save'); + $this->assertResponse(200); + $this->assertText('Thingie'); + + // Update the thingie. + $this->assertLinkByHref('admin/structure/config_test/manage/' . $id); + $edit = array( + 'label' => 'Thongie', + ); + $this->drupalPost('admin/structure/config_test/manage/' . $id, $edit, 'Save'); + $this->assertResponse(200); + $this->assertNoText('Thingie'); + $this->assertText('Thongie'); + + // Delete the thingie. + $this->assertLinkByHref('admin/structure/config_test/manage/' . $id . '/delete'); + $this->drupalPost('admin/structure/config_test/manage/' . $id . '/delete', array(), 'Delete'); + $this->assertResponse(200); + $this->assertNoText('Thingie'); + $this->assertNoText('Thongie'); + + // Re-create a thingie. + $edit = array( + 'id' => $id, + 'label' => 'Thingie', + ); + $this->drupalPost('admin/structure/config_test/add', $edit, 'Save'); + $this->assertResponse(200); + $this->assertText('Thingie'); + + // Rename the thingie's ID/machine name. + $this->assertLinkByHref('admin/structure/config_test/manage/' . $id); + $new_id = 'zingie'; + $edit = array( + 'id' => $new_id, + 'label' => 'Zingie', + ); + $this->drupalPost('admin/structure/config_test/manage/' . $id, $edit, 'Save'); + $this->assertResponse(200); + $this->assertNoText('Thingie'); + $this->assertText('Zingie'); + } +} diff --git a/core/modules/config/lib/Drupal/config/Tests/ConfigFileContentTest.php b/core/modules/config/lib/Drupal/config/Tests/ConfigFileContentTest.php index 3add6d8..abbd2ae 100644 --- a/core/modules/config/lib/Drupal/config/Tests/ConfigFileContentTest.php +++ b/core/modules/config/lib/Drupal/config/Tests/ConfigFileContentTest.php @@ -68,7 +68,7 @@ class ConfigFileContentTest extends WebTestBase { // Verify nothing was saved. $db_data = $database_storage->read($name); - $this->assertIdentical($db_data, array()); + $this->assertIdentical($db_data, FALSE); // Add a top level value $config = config($name); @@ -181,7 +181,7 @@ class ConfigFileContentTest extends WebTestBase { // Verify the database entry no longer exists. $db_data = $database_storage->read($name); - $this->assertIdentical($db_data, array()); + $this->assertIdentical($db_data, FALSE); } /** diff --git a/core/modules/config/lib/Drupal/config/Tests/ConfigImportTest.php b/core/modules/config/lib/Drupal/config/Tests/ConfigImportTest.php new file mode 100644 index 0000000..2248616 --- /dev/null +++ b/core/modules/config/lib/Drupal/config/Tests/ConfigImportTest.php @@ -0,0 +1,209 @@ + 'Import configuration', + 'description' => 'Tests importing configuration from files into active store.', + 'group' => 'Configuration', + ); + } + + function setUp() { + parent::setUp(array('config_test')); + + // Clear out any possibly existing hook invocation records. + unset($GLOBALS['hook_config_test_dynamic']); + } + + /** + * Tests omission of module APIs for bare configuration operations. + */ + function testNoImport() { + $dynamic_name = 'config_test.dynamic.default'; + + // Verify the default configuration values exist. + $config = config($dynamic_name); + $this->assertIdentical($config->get('id'), 'default'); + + // Verify that a bare config() does not involve module APIs. + $this->assertFalse(isset($GLOBALS['hook_config_test_dynamic'])); + + // Export. + config_export(); + + // Verify that config_export() does not involve module APIs. + $this->assertFalse(isset($GLOBALS['hook_config_test_dynamic'])); + } + + /** + * Tests deletion of configuration during import. + */ + function testDeleted() { + $name = 'config_test.system'; + $dynamic_name = 'config_test.dynamic.default'; + + // Verify the default configuration values exist. + $config = config($name); + $this->assertIdentical($config->get('foo'), 'bar'); + $config = config($dynamic_name); + $this->assertIdentical($config->get('id'), 'default'); + + // Export. + config_export(); + + // Delete the configuration objects. + $file_storage = new FileStorage(); + $file_storage->delete($name); + $file_storage->delete($dynamic_name); + + // Import. + config_import(); + + // Verify the values have disappeared. + $database_storage = new DatabaseStorage(); + $this->assertIdentical($database_storage->read($name), FALSE); + $this->assertIdentical($database_storage->read($dynamic_name), FALSE); + + $config = config($name); + $this->assertIdentical($config->get('foo'), NULL); + $config = config($dynamic_name); + $this->assertIdentical($config->get('id'), NULL); + + // Verify that appropriate module API hooks have been invoked. + $this->assertTrue(isset($GLOBALS['hook_config_test_dynamic']['load'])); + $this->assertFalse(isset($GLOBALS['hook_config_test_dynamic']['presave'])); + $this->assertFalse(isset($GLOBALS['hook_config_test_dynamic']['insert'])); + $this->assertFalse(isset($GLOBALS['hook_config_test_dynamic']['update'])); + $this->assertTrue(isset($GLOBALS['hook_config_test_dynamic']['predelete'])); + $this->assertTrue(isset($GLOBALS['hook_config_test_dynamic']['delete'])); + } + + /** + * Tests creation of configuration during import. + */ + function testNew() { + $name = 'config_test.new'; + $dynamic_name = 'config_test.dynamic.new'; + + // Verify the configuration to create does not exist yet. + $file_storage = new FileStorage(); + $this->assertIdentical($file_storage->exists($name), FALSE, $name . ' not found.'); + $this->assertIdentical($file_storage->exists($dynamic_name), FALSE, $dynamic_name . ' not found.'); + + // Export. + config_export(); + + // Create new configuration objects. + $file_storage->write($name, array( + 'add_me' => 'new value', + )); + $file_storage->write($dynamic_name, array( + 'id' => 'new', + 'label' => 'New', + )); + $this->assertIdentical($file_storage->exists($name), TRUE, $name . ' found.'); + $this->assertIdentical($file_storage->exists($dynamic_name), TRUE, $dynamic_name . ' found.'); + + // Import. + config_import(); + + // Verify the values appeared. + $config = config($name); + $this->assertIdentical($config->get('add_me'), 'new value'); + $config = config($dynamic_name); + $this->assertIdentical($config->get('label'), 'New'); + + // Verify that appropriate module API hooks have been invoked. + $this->assertTrue(isset($GLOBALS['hook_config_test_dynamic']['load'])); + $this->assertTrue(isset($GLOBALS['hook_config_test_dynamic']['presave'])); + $this->assertTrue(isset($GLOBALS['hook_config_test_dynamic']['insert'])); + $this->assertFalse(isset($GLOBALS['hook_config_test_dynamic']['update'])); + $this->assertFalse(isset($GLOBALS['hook_config_test_dynamic']['predelete'])); + $this->assertFalse(isset($GLOBALS['hook_config_test_dynamic']['delete'])); + } + + /** + * Tests updating of configuration during import. + */ + function testUpdated() { + $name = 'config_test.system'; + $dynamic_name = 'config_test.dynamic.default'; + + // Export. + config_export(); + + // Replace the file content of the existing configuration objects. + $file_storage = new FileStorage(); + $this->assertIdentical($file_storage->exists($name), TRUE, $name . ' found.'); + $this->assertIdentical($file_storage->exists($dynamic_name), TRUE, $dynamic_name . ' found.'); + $file_storage->write($name, array( + 'foo' => 'beer', + )); + $file_storage->write($dynamic_name, array( + 'id' => 'default', + 'label' => 'Updated', + )); + + // Verify the active store still returns the default values. + $config = config($name); + $this->assertIdentical($config->get('foo'), 'bar'); + $config = config($dynamic_name); + $this->assertIdentical($config->get('label'), 'Default'); + + // Import. + config_import(); + + // Verify the values were updated. + $config = config($name); + $this->assertIdentical($config->get('foo'), 'beer'); + $config = config($dynamic_name); + $this->assertIdentical($config->get('label'), 'Updated'); + + // Verify that appropriate module API hooks have been invoked. + $this->assertTrue(isset($GLOBALS['hook_config_test_dynamic']['load'])); + $this->assertTrue(isset($GLOBALS['hook_config_test_dynamic']['presave'])); + $this->assertFalse(isset($GLOBALS['hook_config_test_dynamic']['insert'])); + $this->assertTrue(isset($GLOBALS['hook_config_test_dynamic']['update'])); + $this->assertFalse(isset($GLOBALS['hook_config_test_dynamic']['predelete'])); + $this->assertFalse(isset($GLOBALS['hook_config_test_dynamic']['delete'])); + } + + /** + * Tests config_import() hook invocations. + */ + function testSyncHooks() { + $name = 'config_test.system'; + $dynamic_name = 'config_test.dynamic.default'; + + // Export. + config_export(); + + // Delete a file so that hook_config_import() hooks are run. + $file_storage = new FileStorage(); + $this->assertIdentical($file_storage->exists($name), TRUE, $name . ' found.'); + $this->assertIdentical($file_storage->exists($dynamic_name), TRUE, $dynamic_name . ' found.'); + $file_storage->delete($name); + $file_storage->delete($dynamic_name); + + // Import. + config_import(); + + // Verify hook_config_import() was invoked. + $this->assertIdentical($GLOBALS['hook_config_import'], 'config_test_config_import'); + } +} diff --git a/core/modules/config/lib/Drupal/config/Tests/ConfigInstallTest.php b/core/modules/config/lib/Drupal/config/Tests/ConfigInstallTest.php new file mode 100644 index 0000000..a9676ef --- /dev/null +++ b/core/modules/config/lib/Drupal/config/Tests/ConfigInstallTest.php @@ -0,0 +1,59 @@ + 'Installation functionality', + 'description' => 'Tests installation of configuration objects in installation functionality.', + 'group' => 'Configuration', + ); + } + + /** + * Tests module installation. + */ + function testModuleInstallation() { + $default_config = 'config_test.system'; + $default_thingie = 'config_test.dynamic.default'; + + // Verify that default module config does not exist before installation yet. + $config = config($default_config); + $this->assertIdentical($config->isNew(), TRUE); + $config = config($default_thingie); + $this->assertIdentical($config->isNew(), TRUE); + + // Install the test module. + module_enable(array('config_test')); + + // Verify that default module config exists. + $config = config($default_config); + $this->assertIdentical($config->isNew(), FALSE); + $config = config($default_thingie); + $this->assertIdentical($config->isNew(), FALSE); + + // Verify that configuration import callback was invoked for the dynamic + // thingie. + $this->assertTrue($GLOBALS['hook_config_import']); + + // Verify that config_test API hooks were invoked for the dynamic default + // thingie. + $this->assertTrue(isset($GLOBALS['hook_config_test_dynamic']['load'])); + $this->assertTrue(isset($GLOBALS['hook_config_test_dynamic']['presave'])); + $this->assertTrue(isset($GLOBALS['hook_config_test_dynamic']['insert'])); + $this->assertFalse(isset($GLOBALS['hook_config_test_dynamic']['update'])); + $this->assertFalse(isset($GLOBALS['hook_config_test_dynamic']['predelete'])); + $this->assertFalse(isset($GLOBALS['hook_config_test_dynamic']['delete'])); + } +} diff --git a/core/modules/config/lib/Drupal/config/Tests/Storage/ConfigStorageTestBase.php b/core/modules/config/lib/Drupal/config/Tests/Storage/ConfigStorageTestBase.php index 3e6b254..2dbc627 100644 --- a/core/modules/config/lib/Drupal/config/Tests/Storage/ConfigStorageTestBase.php +++ b/core/modules/config/lib/Drupal/config/Tests/Storage/ConfigStorageTestBase.php @@ -32,18 +32,18 @@ abstract class ConfigStorageTestBase extends WebTestBase { function testCRUD() { $name = 'config_test.storage'; - // Reading a non-existing name returns an empty data array. + // Reading a non-existing name returns FALSE. $data = $this->storage->read($name); - $this->assertIdentical($data, array()); + $this->assertIdentical($data, FALSE); - // Reading a name containing non-decodeable data returns an empty array. + // Reading a name containing non-decodeable data returns FALSE. $this->insert($name, ''); $data = $this->storage->read($name); - $this->assertIdentical($data, array()); + $this->assertIdentical($data, FALSE); $this->update($name, 'foo'); $data = $this->storage->read($name); - $this->assertIdentical($data, array()); + $this->assertIdentical($data, FALSE); $this->delete($name); @@ -76,9 +76,9 @@ abstract class ConfigStorageTestBase extends WebTestBase { $result = $this->storage->delete($name); $this->assertIdentical($result, FALSE); - // Reading from a non-existing storage bin returns an empty data array. + // Reading from a non-existing storage bin returns FALSE. $data = $this->invalidStorage->read($name); - $this->assertIdentical($data, array()); + $this->assertIdentical($data, FALSE); // Writing to a non-existing storage bin throws an exception. try { diff --git a/core/modules/config/tests/config_test/config/config_test.delete.yml b/core/modules/config/tests/config_test/config/config_test.delete.yml new file mode 100644 index 0000000..b8ccb67 --- /dev/null +++ b/core/modules/config/tests/config_test/config/config_test.delete.yml @@ -0,0 +1 @@ +delete_me: bar diff --git a/core/modules/config/tests/config_test/config/config_test.dynamic.default.yml b/core/modules/config/tests/config_test/config/config_test.dynamic.default.yml new file mode 100644 index 0000000..3e50e3b --- /dev/null +++ b/core/modules/config/tests/config_test/config/config_test.dynamic.default.yml @@ -0,0 +1,2 @@ +id: default +label: Default diff --git a/core/modules/config/tests/config_test/config/config_test.system.yml b/core/modules/config/tests/config_test/config/config_test.system.yml new file mode 100644 index 0000000..20e9ff3 --- /dev/null +++ b/core/modules/config/tests/config_test/config/config_test.system.yml @@ -0,0 +1 @@ +foo: bar diff --git a/core/modules/config/tests/config_test/config_test.hooks.inc b/core/modules/config/tests/config_test/config_test.hooks.inc new file mode 100644 index 0000000..2ec6831 --- /dev/null +++ b/core/modules/config/tests/config_test/config_test.hooks.inc @@ -0,0 +1,52 @@ +save(); + return TRUE; +} + +/** + * Implements MODULE_config_import_change(). + */ +function config_test_config_import_change($name, $new_config, $old_config) { + // Set a global value we can check in test code. + $GLOBALS['hook_config_import'] = __FUNCTION__; + + // Only configurable thingies require custom handling. Any other module + // settings can be synchronized directly. + if (strpos($name, 'config_test.dynamic.') !== 0) { + return FALSE; + } + $config_test = new ConfigTest($new_config); + $config_test->setOriginal($old_config); + $config_test->save(); + return TRUE; +} + +/** + * Implements MODULE_config_import_delete(). + */ +function config_test_config_import_delete($name, $new_config, $old_config) { + // Set a global value we can check in test code. + $GLOBALS['hook_config_import'] = __FUNCTION__; + + // Only configurable thingies require custom handling. Any other module + // settings can be synchronized directly. + if (strpos($name, 'config_test.dynamic.') !== 0) { + return FALSE; + } + $config_test = new ConfigTest($old_config); + $config_test->delete(); + return TRUE; +} + +/** + * Implements hook_menu(). + */ +function config_test_menu() { + $items['admin/structure/config_test'] = array( + 'title' => 'Test configuration', + 'page callback' => 'config_test_list_page', + 'access callback' => TRUE, + ); + $items['admin/structure/config_test/add'] = array( + 'title' => 'Add test configuration', + 'page callback' => 'drupal_get_form', + 'page arguments' => array('config_test_form'), + 'access callback' => TRUE, + 'type' => MENU_LOCAL_ACTION, + ); + $items['admin/structure/config_test/manage/%config_test'] = array( + 'title' => 'Edit test configuration', + 'page callback' => 'drupal_get_form', + 'page arguments' => array('config_test_form', 4), + 'access callback' => TRUE, + ); + $items['admin/structure/config_test/manage/%config_test/edit'] = array( + 'title' => 'Edit', + 'type' => MENU_DEFAULT_LOCAL_TASK, + 'weight' => -10, + ); + $items['admin/structure/config_test/manage/%config_test/delete'] = array( + 'title' => 'Delete', + 'page callback' => 'drupal_get_form', + 'page arguments' => array('config_test_delete_form', 4), + 'access callback' => TRUE, + 'type' => MENU_LOCAL_TASK, + ); + return $items; +} + +/** + * Loads a ConfigTest object. + * + * @param string $id + * The ID of the ConfigTest object to load. + */ +function config_test_load($id) { + $config = config('config_test.dynamic.' . $id); + if ($config->isNew()) { + return FALSE; + } + return new ConfigTest($config); +} + +/** + * Saves a ConfigTest object. + * + * @param Drupal\config_test\ConfigTest $config_test + * The ConfigTest object to save. + */ +function config_test_save(ConfigTest $config_test) { + return $config_test->save(); +} + +/** + * Deletes a ConfigTest object. + * + * @param string $id + * The ID of the ConfigTest object to delete. + */ +function config_test_delete($id) { + $config = config('config_test.dynamic.' . $id); + $config_test = new ConfigTest($config); + return $config_test->delete(); +} + +/** + * Page callback; Lists available ConfigTest objects. + */ +function config_test_list_page() { + $config_names = config_get_storage_names_with_prefix('config_test.dynamic.'); + $rows = array(); + foreach ($config_names as $config_name) { + $config_test = new ConfigTest(config($config_name)); + $row = array(); + $row['name']['data'] = array( + '#type' => 'link', + '#title' => $config_test->getLabel(), + '#href' => $config_test->getUri(), + ); + $row['delete']['data'] = array( + '#type' => 'link', + '#title' => t('Delete'), + '#href' => $config_test->getUri() . '/delete', + ); + $rows[] = $row; + } + $build = array( + '#theme' => 'table', + '#header' => array('Name', 'Operations'), + '#rows' => $rows, + '#empty' => format_string('No test configuration defined. Add some', array( + '@add-url' => url('admin/structure/config_test/add'), + )), + ); + return $build; +} + +/** + * Form constructor to add or edit a ConfigTest object. + * + * @param Drupal\config_test\ConfigTest $config_test + * (optional) An existing ConfigTest object to edit. If omitted, the form + * creates a new ConfigTest. + */ +function config_test_form($form, &$form_state, ConfigTest $config_test = NULL) { + if (!isset($config_test)) { + $config_test = new ConfigTest(config(NULL)); + } + $form_state['config_test'] = $config_test; + + $form['label'] = array( + '#type' => 'textfield', + '#title' => 'Label', + '#default_value' => $config_test->getLabel(), + '#required' => TRUE, + ); + $form['id'] = array( + '#type' => 'machine_name', + '#default_value' => $config_test->getID(), + '#required' => TRUE, + '#machine_name' => array( + 'exists' => 'config_test_load', + // @todo Update form_process_machine_name() to use 'label' by default. + 'source' => array('label'), + ), + ); + $form['style'] = array( + '#type' => 'select', + '#title' => 'Image style', + '#options' => array(), + '#default_value' => $config_test->get('style'), + '#access' => FALSE, + ); + if (module_exists('image')) { + $form['style']['#access'] = TRUE; + $form['style']['#options'] = image_style_options(); + } + + $form['actions'] = array('#type' => 'actions'); + $form['actions']['submit'] = array('#type' => 'submit', '#value' => 'Save'); + + return $form; +} + +/** + * Form submission handler for config_test_form(). + */ +function config_test_form_submit($form, &$form_state) { + form_state_values_clean($form_state); + + $config_test = $form_state['config_test']; + + foreach ($form_state['values'] as $key => $value) { + $config_test->set($key, $value); + } + $config_test->save(); + + if (!empty($config_test->original)) { + drupal_set_message(format_string('%label configuration has been updated.', array('%label' => $config_test->getLabel()))); + } + else { + drupal_set_message(format_string('%label configuration has been created.', array('%label' => $config_test->getLabel()))); + } + + $form_state['redirect'] = 'admin/structure/config_test'; +} + +/** + * Form constructor to delete a ConfigTest object. + * + * @param Drupal\config_test\ConfigTest $config_test + * The ConfigTest object to delete. + */ +function config_test_delete_form($form, &$form_state, ConfigTest $config_test) { + $form_state['config_test'] = $config_test; + + $form['id'] = array('#type' => 'value', '#value' => $config_test->getID()); + return confirm_form($form, + format_string('Are you sure you want to delete %label', array('%label' => $config_test->getLabel())), + 'admin/structure/config_test', + NULL, + 'Delete' + ); +} + +/** + * Form submission handler for config_test_delete_form(). + */ +function config_test_delete_form_submit($form, &$form_state) { + $form_state['config_test']->delete(); + $form_state['redirect'] = 'admin/structure/config_test'; +} diff --git a/core/modules/config/tests/config_test/lib/Drupal/config_test/ConfigTest.php b/core/modules/config/tests/config_test/lib/Drupal/config_test/ConfigTest.php new file mode 100644 index 0000000..4efaae5 --- /dev/null +++ b/core/modules/config/tests/config_test/lib/Drupal/config_test/ConfigTest.php @@ -0,0 +1,36 @@ +getID(); + } +} diff --git a/core/modules/image/image.module b/core/modules/image/image.module index f3bf83d..397ac95 100644 --- a/core/modules/image/image.module +++ b/core/modules/image/image.module @@ -500,6 +500,50 @@ function image_path_flush($path) { } /** + * Implements MODULE_config_import_create(). + */ +function image_config_import_create($name, $new_config, $old_config) { + // Only image styles require custom handling. Any other module settings can be + // synchronized directly. + if (strpos($name, 'image.style.') !== 0) { + return FALSE; + } + $style = $new_config->get(); + return image_style_save($style); +} + +/** + * Implements MODULE_config_import_change(). + */ +function image_config_import_change($name, $new_config, $old_config) { + // Only image styles require custom handling. Any other module settings can be + // synchronized directly. + if (strpos($name, 'image.style.') !== 0) { + return FALSE; + } + $style = $new_config->get(); + return image_style_save($style); +} + +/** + * Implements MODULE_config_import_delete(). + */ +function image_config_import_delete($name, $new_config, $old_config) { + // Only image styles require custom handling. Any other module settings can be + // synchronized directly. + if (strpos($name, 'image.style.') !== 0) { + return FALSE; + } + // @todo image_style_delete() supports the notion of a "replacement style" + // to be used by other modules instead of the deleted style. Essential! + // But that is impossible currently, since the config system only knows + // about deleted and added changes. Introduce an 'old_ID' key within + // config objects as a standard? + $style = $old_config->get(); + return image_style_delete($style); +} + +/** * Get an array of all styles and their settings. * * @return @@ -548,13 +592,11 @@ function image_styles() { * @see image_effect_load() */ function image_style_load($name) { - $style = config('image.style.' . $name)->get(); - - // @todo Requires a more reliable + generic method to check for whether the - // configuration object exists. - if (!isset($style['name'])) { + $config = config('image.style.' . $name); + if ($config->isNew()) { return FALSE; } + $style = $config->get(); if (!empty($style['effects'])) { foreach ($style['effects'] as $ieid => $effect) { @@ -579,6 +621,8 @@ function image_style_load($name) { */ function image_style_save($style) { $config = config('image.style.' . $style['name']); + $is_new = $config->isNew(); + $config->set('name', $style['name']); if (isset($style['effects'])) { $config->set('effects', $style['effects']); @@ -587,11 +631,9 @@ function image_style_save($style) { $config->set('effects', array()); } $config->save(); - // @todo is_new must only be set when the configuration object did not exist - // yet. - $style['is_new'] = TRUE; // Let other modules update as necessary on save. + $style['is_new'] = $is_new; module_invoke_all('image_style_save', $style); // Clear all caches and flush.