diff --git a/core/includes/config.inc b/core/includes/config.inc index 51476b7..c143dd4 100644 --- a/core/includes/config.inc +++ b/core/includes/config.inc @@ -91,13 +91,13 @@ function config_sync_get_changes(StorageInterface $source_storage, StorageInterf 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) { + 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. + // changes in any category. if (empty($config_changes['create']) && empty($config_changes['change']) && empty($config_changes['delete'])) { return FALSE; } @@ -129,6 +129,48 @@ function config_sync_changes(array $config_changes, StorageInterface $source_sto } /** + * Imports configuration from FileStorage to DatabaseStorage. + * + * @return bool|null + * TRUE if configuration was imported successfully, FALSE in case of a + * synchronization error, or NULL if there are no changes to synchronize. + */ +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 differentiate between + // an actual synchronization error and a failed lock, because concurrent + // synchronizations are an edge-case happening only when multiple developers + // or site builders attempt to do it without coordinating. + return FALSE; + } + + $success = TRUE; + 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); + $success = FALSE; + } + lock_release(__FUNCTION__); + return $success; +} + +/** * Invokes MODULE_config_import() callbacks for configuration changes. * * @param array $config_changes @@ -176,3 +218,20 @@ function config_import_invoke_owner(array $config_changes, StorageInterface $sou } 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/modules/config/config.admin.inc b/core/modules/config/config.admin.inc new file mode 100644 index 0000000..1fe14b2 --- /dev/null +++ b/core/modules/config/config.admin.inc @@ -0,0 +1,117 @@ + 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 constructor for configuration import form. + * + * @see config_admin_import_form_submit() + */ +function config_admin_import_form($form, &$form_state) { + // Retrieve a list of differences between FileStorage and DatabaseStorage. + // @todo Leverage DI + config.storage.info. + $source_storage = new FileStorage(); + $target_storage = new DatabaseStorage(); + + config_admin_sync_form($form, $form_state, $source_storage, $target_storage); + + $form['actions'] = array('#type' => 'actions'); + $form['actions']['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'); + } +} + +/** + * Form constructor for configuration export form. + * + * @see config_admin_export_form_submit() + */ +function config_admin_export_form($form, &$form_state) { + // Retrieve a list of differences between DatabaseStorage and FileStorage. + // @todo Leverage DI + config.storage.info. + $source_storage = new DatabaseStorage(); + $target_storage = new FileStorage(); + + config_admin_sync_form($form, $form_state, $source_storage, $target_storage); + + $form['actions'] = array('#type' => 'actions'); + $form['actions']['submit'] = array( + '#type' => 'submit', + '#value' => t('Export'), + ); + return $form; +} + +/** + * Form submission handler for config_admin_export_form(). + */ +function config_admin_export_form_submit($form, &$form_state) { + config_export(); + drupal_set_message(t('The configuration was exported successfully.')); +} + diff --git a/core/modules/config/config.module b/core/modules/config/config.module index b3d9bbc..fd38fd2 100644 --- a/core/modules/config/config.module +++ b/core/modules/config/config.module @@ -1 +1,46 @@ t('Synchronize configuration'), + 'restrict access' => TRUE, + ); + return $permissions; +} + +/** + * Implements hook_menu(). + */ +function config_menu() { + $items['admin/config/development/sync'] = array( + 'title' => 'Synchronize configuration', + 'description' => 'Synchronize configuration changes.', + 'page callback' => 'drupal_get_form', + 'page arguments' => array('config_admin_import_form'), + 'access arguments' => array('synchronize configuration'), + 'file' => 'config.admin.inc', + ); + $items['admin/config/development/sync/import'] = array( + 'title' => 'Import', + 'type' => MENU_DEFAULT_LOCAL_TASK, + 'weight' => -10, + ); + $items['admin/config/development/sync/export'] = array( + 'title' => 'Export', + 'page callback' => 'drupal_get_form', + 'page arguments' => array('config_admin_export_form'), + 'access arguments' => array('synchronize configuration'), + 'file' => 'config.admin.inc', + 'type' => MENU_LOCAL_TASK, + ); + return $items; +} + 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..a6e72b4 --- /dev/null +++ b/core/modules/config/lib/Drupal/config/Tests/ConfigImportTest.php @@ -0,0 +1,138 @@ + 'Import configuration', + 'description' => 'Tests importing configuration from files into active store.', + 'group' => 'Configuration', + ); + } + + function setUp() { + parent::setUp(array('config_test')); + } + + /** + * 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); + } + + /** + * 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'); + } + + /** + * 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'); + } +} diff --git a/core/modules/config/lib/Drupal/config/Tests/ConfigImportUITest.php b/core/modules/config/lib/Drupal/config/Tests/ConfigImportUITest.php new file mode 100644 index 0000000..6b64b22 --- /dev/null +++ b/core/modules/config/lib/Drupal/config/Tests/ConfigImportUITest.php @@ -0,0 +1,130 @@ + 'Import/Export UI', + 'description' => 'Tests the user interface for importing/exporting configuration.', + 'group' => 'Configuration', + ); + } + + function setUp() { + parent::setUp(array('config', 'config_test')); + + $this->web_user = $this->drupalCreateUser(array('synchronize configuration')); + $this->drupalLogin($this->web_user); + } + + /** + * Tests exporting configuration. + */ + function testExport() { + $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'); + + // Verify that both appear as deleted by default. + $this->drupalGet('admin/config/development/sync/export'); + $this->assertText($name); + $this->assertText($dynamic_name); + + // Export and verify that both do not appear anymore. + $this->drupalPost(NULL, array(), t('Export')); + $this->assertUrl('admin/config/development/sync/export'); + $this->assertNoText($name); + $this->assertNoText($dynamic_name); + + // Verify that there are no further changes to export. + $this->assertText(t('There are no configuration changes.')); + + // Verify that the import screen shows no changes either. + $this->drupalGet('admin/config/development/sync'); + $this->assertText(t('There are no configuration changes.')); + } + + /** + * Tests importing configuration. + */ + function testImport() { + $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.'); + + // Verify that both appear as new. + $this->drupalGet('admin/config/development/sync'); + $this->assertText($name); + $this->assertText($dynamic_name); + + // Import and verify that both do not appear anymore. + $this->drupalPost(NULL, array(), t('Import')); + $this->assertUrl('admin/config/development/sync'); + $this->assertNoText($name); + $this->assertNoText($dynamic_name); + + // Verify that there are no further changes to import. + $this->assertText(t('There are no configuration changes.')); + + // Verify that the export screen shows no changes either. + $this->drupalGet('admin/config/development/sync/export'); + $this->assertText(t('There are no configuration changes.')); + } + + /** + * Tests concurrent importing of configuration. + */ + function testImportLock() { + // Verify that there are configuration differences to import. + $this->drupalGet('admin/config/development/sync'); + $this->assertNoText(t('There are no configuration changes.')); + + // Acquire a fake-lock on the import mechanism. + $lock_name = 'config_import'; + lock_acquire($lock_name); + + // Attempt to import configuration and verify that an error message appears. + $this->drupalPost(NULL, array(), t('Import')); + $this->assertUrl('admin/config/development/sync'); + $this->assertText(t('The import failed due to an error. Any errors have been logged.')); + + // Release the lock, just to keep testing sane. + lock_release($lock_name); + } +} 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