Problem/Motivation
Currently "safe" updates are those where no customization has occurred on the site. However, an update could also be considered safe if customization has occurred but updates are selectively merged in.
Such merge logic should consult configuration schema data to infer appropriate merge strategies.
Examples of safely merged updates
Merging configuration where there is no conflict:
As originally installed:
block_content.type.basic.yml
langcode: en
status: true
dependencies: { }
id: basic
label: 'Basic block'
revision: 0
description: 'A basic block contains a title and a body.'
As customized (description changed) :
block_content.type.basic.yml
langcode: en
status: true
dependencies: { }
id: basic
label: 'Basic block'
revision: 0
description: 'A basic block contains a title, a body, and an image.'
As updated in config/install (label changed):
block_content.type.basic.yml
langcode: en
status: true
dependencies: { }
id: basic
label: 'Basic'
revision: 0
description: 'A basic block contains a title and a body.'
Result of safe merging:
block_content.type.basic.yml
langcode: en
status: true
dependencies: { }
id: basic
label: 'Basic'
revision: 0
description: 'A basic block contains a title, a body, and an image.'
There is no conflict so the merged update retains all customizations and all updates.
Merging configuration where there is a conflict:
As originally installed:
block_content.type.basic.yml
langcode: en
status: true
dependencies: { }
id: basic
label: 'Basic block'
revision: 0
description: 'A basic block contains a title and a body.'
As customized (label changed):
block_content.type.basic.yml
langcode: en
status: true
dependencies: { }
id: basic
label: 'Standard block'
revision: 0
description: 'A basic block contains a title and a body.'
As updated in config/install (label and description changed):
block_content.type.basic.yml
langcode: en
status: true
dependencies: { }
id: basic
label: 'Basic'
revision: 0
description: 'A basic block has a title and a body.'
Result of safe merging:
block_content.type.basic.yml
langcode: en
status: true
dependencies: { }
id: basic
label: 'Standard block'
revision: 0
description: 'A basic block has a title and a body.'
The label is changed in both places and so the customization takes precedence.
Proposed resolution
For a given configuration item, there are three states to selectively merge:
- Previous. As originally provided in e.g. config/install (from snapshot).
- Current. As currently provided in e.g. config/install.
- Active. As currently saved in active configuration.
If we consult the configuration schema we can differentiate sequences (which should be handled as indexed arrays) from mappings (associative arrays). However, in practice, some sequences are associative arrays, since a sequence can be keyed by UUID. It might be sufficient to evaluate the incoming data to distinguish between indexed and associative arrays.
TBD:
- Should we determine if the schema has changed? If so, what should we do with that information?
- Is it a valid assumption that all sequences should have only unique values, while mappings can have duplicate values?
Strategy for merging:
- Active is the merge target. We will accept all values of Active unless they're overwritten by a change from Current.
- Start by comparing Previous to Current. We're only concerned with items that are not themselves arrays and have been removed, added, or (in the case of associative array values) changed.
- For each such item, determine if the array the item is a member of is indexed or associative.
- If associative, look for a corresponding key in Previous and Active. If present and different (changed), do nothing; this is a value that has been customized. If present and the same in Active as Previous, this hasn't been customized, so overwrite the value in Active with that from Current. If not present, fall through to the handling for non-associative array values.
- For indexed (non-associative) array values, for values that have been added (present in Current but not in Previous), add to Active if not already present. For values that have been removed (present in Previous but not in Current), remove from Active if present.
We could consider a method that accepts three arrays:
/**
* Merges changes to a configuration item into the active storage.
*
* @param $previous
* The configuration item as previously provided (from snapshot).
* @param $current
* The configuration item as currently provided by an extension.
* @param $active
* The configuration item as present in the active storage.
*/
public function mergeConfigItemChanges(array $previous, array $current, array $active) {
}
As a first step, here is an initial reworking of NestedArray::mergeDeepArray() with the following differences:
- Rather than passing in the
$preserve_integer_keysargument to determine how values with integer keys should be handled, analyze the array in the same way as Symfony's Yaml component does when dumping an array. This makes the code more flexible. - Ensure values are unique for indexed (non-associative) arrays.
This doesn't yet:
- Consult configuration schemas.
- Differentiate between configuration versions A, B, and C (see above).
public static function mergeDeepArray(array $arrays) {
$result = array();
foreach ($arrays as $array) {
// Analyze the array to determine if we should preserve integer keys. Use the same logic as when dumping into Yaml.
// See \Symfony\Component\Yaml\Inline::dumpArray().
$keys = array_keys($array);
$keys_count = count($keys);
if ((1 === $keys_count && '0' == $keys[0])
|| ($keys_count > 1 && array_reduce($keys, function ($v, $w) { return (int) $v + $w; }, 0) === $keys_count * ($keys_count - 1) / 2)
) {
$is_associative = FALSE;
}
else {
$is_associative = TRUE;
}
foreach ($array as $key => $value) {
// Renumber integer keys as array_merge_recursive() does unless
// $is_associative is set to TRUE. Note that PHP automatically
// converts array keys that are integer strings (e.g., '1') to integers.
if (is_integer($key) && !$is_associative) {
// Add if this is not already added.
if (!!in_array($value, $result)) {
$result[] = $value;
}
}
// Recurse when both values are arrays.
elseif (isset($result[$key]) && is_array($result[$key]) && is_array($value)) {
$result[$key] = self::mergeDeepArray(array($result[$key], $value));
}
// Otherwise, use the latter value, overriding any previous value.
else {
// Duplicate values are acceptable if we have different keys, so don't prevent duplicates.
$result[$key] = $value;
}
}
}
return $result;
}
Comments
Comment #2
nedjoComment #3
nedjoRoughing in ideas on merging strategies.
Comment #4
nedjoComment #5
nedjoComment #6
nedjoBreaking the work into child issues, starting with #2625092: Provide method for merging configuration item changes.
Comment #7
nedjoCode is now using the method introduced in #2625092: Provide method for merging configuration item changes.