Browse Source
feat(lexicon): migrate config key/value
feat(lexicon): migrate config key/value
Signed-off-by: Maxence Lange <maxence@artificial-owl.com>pull/52832/head
17 changed files with 491 additions and 66 deletions
-
2core/Command/Config/App/Base.php
-
25core/Command/Config/App/SetConfig.php
-
7core/Command/Config/ListConfigs.php
-
2lib/composer/composer/autoload_classmap.php
-
2lib/composer/composer/autoload_static.php
-
5lib/private/App/AppManager.php
-
79lib/private/AppConfig.php
-
252lib/private/Config/ConfigManager.php
-
86lib/private/Config/UserConfig.php
-
29lib/private/Repair/ConfigKeyMigration.php
-
16lib/private/Server.php
-
25lib/unstable/Config/Lexicon/ConfigLexiconEntry.php
-
5tests/Core/Command/Config/App/DeleteConfigTest.php
-
5tests/Core/Command/Config/App/GetConfigTest.php
-
5tests/Core/Command/Config/App/SetConfigTest.php
-
10tests/Core/Command/Config/ListConfigsTest.php
-
2tests/lib/DB/AdapterTest.php
@ -0,0 +1,252 @@ |
|||
<?php |
|||
|
|||
declare(strict_types=1); |
|||
/** |
|||
* SPDX-FileCopyrightText: 2025 Nextcloud GmbH and Nextcloud contributors |
|||
* SPDX-License-Identifier: AGPL-3.0-or-later |
|||
*/ |
|||
|
|||
namespace OC\Config; |
|||
|
|||
use JsonException; |
|||
use NCU\Config\Exceptions\TypeConflictException; |
|||
use NCU\Config\IUserConfig; |
|||
use NCU\Config\Lexicon\ConfigLexiconEntry; |
|||
use NCU\Config\ValueType; |
|||
use OC\AppConfig; |
|||
use OCP\App\IAppManager; |
|||
use OCP\IAppConfig; |
|||
use Psr\Log\LoggerInterface; |
|||
|
|||
class ConfigManager { |
|||
public function __construct( |
|||
/** @var AppConfig $appConfig */ |
|||
private readonly IAppConfig $appConfig, |
|||
/** @var UserConfig $appConfig */ |
|||
private readonly IUserConfig $userConfig, |
|||
private readonly IAppManager $appManager, |
|||
private readonly LoggerInterface $logger, |
|||
) { |
|||
} |
|||
|
|||
/** |
|||
* Use the rename values from the list of ConfigLexiconEntry defined in each app ConfigLexicon |
|||
* to migrate config value to a new config key. |
|||
* Migration will only occur if new config key has no value in database. |
|||
* The previous value from the key set in rename will be deleted from the database when migration |
|||
* is over. |
|||
* |
|||
* This method should be mainly called during a new upgrade or when a new app is enabled. |
|||
* |
|||
* @see ConfigLexiconEntry |
|||
* @internal |
|||
* @param string|null $appId when set to NULL the method will be executed for all enabled apps of the instance |
|||
*/ |
|||
public function migrateConfigLexiconKeys(?string $appId = null): void { |
|||
if ($appId === null) { |
|||
$this->migrateConfigLexiconKeys('core'); |
|||
foreach ($this->appManager->getEnabledApps() as $app) { |
|||
$this->migrateConfigLexiconKeys($app); |
|||
} |
|||
|
|||
return; |
|||
} |
|||
|
|||
// it is required to ignore aliases when moving config values
|
|||
$this->appConfig->ignoreLexiconAliases(true); |
|||
$this->userConfig->ignoreLexiconAliases(true); |
|||
|
|||
$this->migrateAppConfigKeys($appId); |
|||
$this->migrateUserConfigKeys($appId); |
|||
|
|||
// switch back to normal behavior
|
|||
$this->appConfig->ignoreLexiconAliases(false); |
|||
$this->userConfig->ignoreLexiconAliases(false); |
|||
} |
|||
|
|||
/** |
|||
* Get details from lexicon related to AppConfig and search for entries with rename to initiate |
|||
* a migration to new config key |
|||
*/ |
|||
private function migrateAppConfigKeys(string $appId): void { |
|||
$lexicon = $this->appConfig->getConfigDetailsFromLexicon($appId); |
|||
|
|||
// we store a list of config keys to compare with any 'copyFrom'
|
|||
$keys = []; |
|||
foreach ($lexicon['entries'] as $entry) { |
|||
$keys[] = $entry->getKey(); |
|||
} |
|||
|
|||
foreach ($lexicon['entries'] as $entry) { |
|||
// only interested in entries with rename set
|
|||
if ($entry->getRename() === null) { |
|||
continue; |
|||
} |
|||
|
|||
if (in_array($entry->getRename(), $keys, true)) { |
|||
$this->logger->error('rename value should not exist as a valid config key within Lexicon'); |
|||
continue; |
|||
} |
|||
|
|||
// only migrate if rename config key has a value and the new config key hasn't
|
|||
if ($this->appConfig->hasKey($appId, $entry->getRename()) |
|||
&& !$this->appConfig->hasKey($appId, $entry->getKey())) { |
|||
try { |
|||
$this->migrateAppConfigValue($appId, $entry); |
|||
} catch (TypeConflictException $e) { |
|||
$this->logger->error('could not migrate AppConfig value', ['appId' => $appId, 'entry' => $entry, 'exception' => $e]); |
|||
continue; |
|||
} |
|||
} |
|||
|
|||
// we only delete previous config value if migration went fine.
|
|||
$this->appConfig->deleteKey($appId, $entry->getRename()); |
|||
} |
|||
} |
|||
|
|||
/** |
|||
* Get details from lexicon related to UserConfig and search for entries with rename to initiate |
|||
* a migration to new config key |
|||
*/ |
|||
private function migrateUserConfigKeys(string $appId): void { |
|||
$lexicon = $this->userConfig->getConfigDetailsFromLexicon($appId); |
|||
|
|||
// we store a list of set keys to compare with any 'copyFrom'
|
|||
$keys = []; |
|||
foreach ($lexicon['entries'] as $entry) { |
|||
$keys[] = $entry->getKey(); |
|||
} |
|||
|
|||
foreach ($lexicon['entries'] as $entry) { |
|||
// only interested in keys with rename set
|
|||
if ($entry->getRename() === null) { |
|||
continue; |
|||
} |
|||
|
|||
if (in_array($entry->getRename(), $keys, true)) { |
|||
$this->logger->error('rename value should not exist as a valid key within Lexicon'); |
|||
continue; |
|||
} |
|||
|
|||
foreach ($this->userConfig->getValuesByUsers($appId, $entry->getRename()) as $userId => $value) { |
|||
if ($this->userConfig->hasKey($userId, $appId, $entry->getKey())) { |
|||
continue; |
|||
} |
|||
|
|||
try { |
|||
$this->migrateUserConfigValue($userId, $appId, $entry); |
|||
} catch (TypeConflictException $e) { |
|||
$this->logger->error('could not migrate UserConfig value', ['userId' => $userId, 'appId' => $appId, 'entry' => $entry, 'exception' => $e]); |
|||
continue; |
|||
} |
|||
|
|||
$this->userConfig->deleteUserConfig($userId, $appId, $entry->getRename()); |
|||
} |
|||
} |
|||
} |
|||
|
|||
|
|||
/** |
|||
* converting value from rename to the new key |
|||
* |
|||
* @throws TypeConflictException if previous value does not fit the expected type |
|||
*/ |
|||
private function migrateAppConfigValue(string $appId, ConfigLexiconEntry $entry): void { |
|||
$value = $this->appConfig->getValueMixed($appId, $entry->getRename(), lazy: null); |
|||
switch ($entry->getValueType()) { |
|||
case ValueType::STRING: |
|||
$this->appConfig->setValueString($appId, $entry->getKey(), $value); |
|||
return; |
|||
|
|||
case ValueType::INT: |
|||
$this->appConfig->setValueInt($appId, $entry->getKey(), $this->convertToInt($value)); |
|||
return; |
|||
|
|||
case ValueType::FLOAT: |
|||
$this->appConfig->setValueFloat($appId, $entry->getKey(), $this->convertToFloat($value)); |
|||
return; |
|||
|
|||
case ValueType::BOOL: |
|||
$this->appConfig->setValueBool($appId, $entry->getKey(), $this->convertToBool($value, $entry)); |
|||
return; |
|||
|
|||
case ValueType::ARRAY: |
|||
$this->appConfig->setValueArray($appId, $entry->getKey(), $this->convertToArray($value)); |
|||
return; |
|||
} |
|||
} |
|||
|
|||
/** |
|||
* converting value from rename to the new key |
|||
* |
|||
* @throws TypeConflictException if previous value does not fit the expected type |
|||
*/ |
|||
private function migrateUserConfigValue(string $userId, string $appId, ConfigLexiconEntry $entry): void { |
|||
$value = $this->userConfig->getValueMixed($userId, $appId, $entry->getRename(), lazy: null); |
|||
switch ($entry->getValueType()) { |
|||
case ValueType::STRING: |
|||
$this->userConfig->setValueString($userId, $appId, $entry->getKey(), $value); |
|||
return; |
|||
|
|||
case ValueType::INT: |
|||
$this->userConfig->setValueInt($userId, $appId, $entry->getKey(), $this->convertToInt($value)); |
|||
return; |
|||
|
|||
case ValueType::FLOAT: |
|||
$this->userConfig->setValueFloat($userId, $appId, $entry->getKey(), $this->convertToFloat($value)); |
|||
return; |
|||
|
|||
case ValueType::BOOL: |
|||
$this->userConfig->setValueBool($userId, $appId, $entry->getKey(), $this->convertToBool($value, $entry)); |
|||
return; |
|||
|
|||
case ValueType::ARRAY: |
|||
$this->userConfig->setValueArray($userId, $appId, $entry->getKey(), $this->convertToArray($value)); |
|||
return; |
|||
} |
|||
} |
|||
|
|||
public function convertToInt(string $value): int { |
|||
if ($value !== ((string)((int)$value))) { |
|||
throw new TypeConflictException('Value is not an integer'); |
|||
} |
|||
|
|||
return (int)$value; |
|||
} |
|||
|
|||
public function convertToFloat(string $value): float { |
|||
if ($value !== ((string)((float)$value))) { |
|||
throw new TypeConflictException('Value is not a float'); |
|||
} |
|||
|
|||
return (float)$value; |
|||
} |
|||
|
|||
public function convertToBool(string $value, ?ConfigLexiconEntry $entry = null): bool { |
|||
if (in_array(strtolower($value), ['true', '1', 'on', 'yes'])) { |
|||
$valueBool = true; |
|||
} elseif (in_array(strtolower($value), ['false', '0', 'off', 'no'])) { |
|||
$valueBool = false; |
|||
} else { |
|||
throw new TypeConflictException('Value cannot be converted to boolean'); |
|||
} |
|||
if ($entry?->hasOption(ConfigLexiconEntry::RENAME_INVERT_BOOLEAN) === true) { |
|||
$valueBool = !$valueBool; |
|||
} |
|||
|
|||
return $valueBool; |
|||
} |
|||
|
|||
public function convertToArray(string $value): array { |
|||
try { |
|||
$valueArray = json_decode($value, true, flags: JSON_THROW_ON_ERROR); |
|||
} catch (JsonException) { |
|||
throw new TypeConflictException('Value is not a valid json'); |
|||
} |
|||
if (!is_array($valueArray)) { |
|||
throw new TypeConflictException('Value is not an array'); |
|||
} |
|||
|
|||
return $valueArray; |
|||
} |
|||
} |
|||
@ -0,0 +1,29 @@ |
|||
<?php |
|||
|
|||
declare(strict_types=1); |
|||
|
|||
/** |
|||
* SPDX-FileCopyrightText: 2025 Nextcloud GmbH and Nextcloud contributors |
|||
* SPDX-License-Identifier: AGPL-3.0-or-later |
|||
*/ |
|||
|
|||
namespace OC\Repair; |
|||
|
|||
use OC\Config\ConfigManager; |
|||
use OCP\Migration\IOutput; |
|||
use OCP\Migration\IRepairStep; |
|||
|
|||
class ConfigKeyMigration implements IRepairStep { |
|||
public function __construct( |
|||
private ConfigManager $configManager, |
|||
) { |
|||
} |
|||
|
|||
public function getName(): string { |
|||
return 'Initiate config keys migration'; |
|||
} |
|||
|
|||
public function run(IOutput $output) { |
|||
$this->configManager->migrateConfigLexiconKeys(); |
|||
} |
|||
} |
|||
Write
Preview
Loading…
Cancel
Save
Reference in new issue