Skip to content

Commit fc9bfac

Browse files
committed
[TASK] Extract settings tree diff into a object and add tests
Split the `computeSettingsDiff` into a self-constructing data object and a form-data transformation backed by the site settings factory. The diff algorithm does not depend on any site context and is therefore implemented generically. Currently missing tests are added along the way. Arrays are now transformed into settings objects as early as possible in order to reduce the amount of different arrays formats (settings map vs tree) that are passed around. Releases: main, 13.4 Resolves: #104955 Change-Id: Ifb84e8e4af41340fb60c1627d07c85bda85af2ee Reviewed-on: https://review.typo3.org/c/Packages/TYPO3.CMS/+/86084 Tested-by: Andreas Kienast <akienast@scripting-base.de> Reviewed-by: Benjamin Franzke <ben@bnf.dev> Tested-by: Benjamin Franzke <ben@bnf.dev> Tested-by: Benni Mack <benni@typo3.org> Tested-by: core-ci <typo3@b13.com> Reviewed-by: Andreas Kienast <akienast@scripting-base.de> Reviewed-by: Benni Mack <benni@typo3.org>
1 parent e72ccbc commit fc9bfac

File tree

5 files changed

+320
-91
lines changed

5 files changed

+320
-91
lines changed

typo3/sysext/backend/Classes/Controller/SiteSettingsController.php

Lines changed: 9 additions & 6 deletions
Original file line numberDiff line numberDiff line change
@@ -203,17 +203,18 @@ public function saveAction(ServerRequestInterface $request): ResponseInterface
203203
return new RedirectResponse($returnUrl ?? $overviewUrl);
204204
}
205205

206-
$changes = $this->siteSettingsService->computeSettingsDiff($site, $parsedBody['settings'] ?? []);
207-
$this->siteSettingsService->writeSettings($site, $changes['settings']);
206+
$newSettings = $this->siteSettingsService->createSettingsFromFormData($site, $parsedBody['settings'] ?? []);
207+
$settingsDiff = $this->siteSettingsService->computeSettingsDiff($site, $newSettings);
208+
$this->siteSettingsService->writeSettings($site, $settingsDiff->asArray());
208209

209-
if ($changes['changes'] !== [] || $changes['deletions'] !== []) {
210+
if ($settingsDiff->changes !== [] || $settingsDiff->deletions !== []) {
210211
$this->getBackendUser()->writelog(
211212
Type::SITE,
212213
SettingAction::CHANGE,
213214
SystemLogErrorClassification::MESSAGE,
214215
null,
215216
'Site settings changed for \'%s\': %s',
216-
[$site->getIdentifier(), json_encode($changes)],
217+
[$site->getIdentifier(), json_encode($settingsDiff)],
217218
'site'
218219
);
219220

@@ -246,8 +247,10 @@ public function dumpAction(ServerRequestInterface $request): ResponseInterface
246247
$specificSetting = (string)($parsedBody['specificSetting'] ?? '');
247248

248249
$minify = $specificSetting !== '' ? false : true;
249-
$changes = $this->siteSettingsService->computeSettingsDiff($site, $parsedBody['settings'] ?? [], $minify);
250-
$settings = $changes['settings'];
250+
251+
$newSettings = $this->siteSettingsService->createSettingsFromFormData($site, $parsedBody['settings'] ?? []);
252+
$settingsDiff = $this->siteSettingsService->computeSettingsDiff($site, $newSettings, $minify);
253+
$settings = $settingsDiff->asArray();
251254
if ($specificSetting !== '') {
252255
$value = ArrayUtility::getValueByPath($settings, $specificSetting, '.');
253256
$settings = ArrayUtility::setValueByPath([], $specificSetting, $value, '.');
Lines changed: 115 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,115 @@
1+
<?php
2+
3+
declare(strict_types=1);
4+
5+
/*
6+
* This file is part of the TYPO3 CMS project.
7+
*
8+
* It is free software; you can redistribute it and/or modify it under
9+
* the terms of the GNU General Public License, either version 2
10+
* of the License, or any later version.
11+
*
12+
* For the full copyright and license information, please read the
13+
* LICENSE.txt file that was distributed with this source code.
14+
*
15+
* The TYPO3 project - inspiring people to share!
16+
*/
17+
18+
namespace TYPO3\CMS\Core\Settings;
19+
20+
use TYPO3\CMS\Core\Utility\ArrayUtility;
21+
22+
/**
23+
* @internal
24+
*/
25+
final readonly class SettingsTree
26+
{
27+
/**
28+
* @param string[] $changes
29+
* @param string[] $deletions
30+
*/
31+
public function __construct(
32+
public array $settingsTree,
33+
public array $changes,
34+
public array $deletions,
35+
) {}
36+
37+
public function asArray(): array
38+
{
39+
return $this->settingsTree;
40+
}
41+
42+
/**
43+
* Calculate a new settings tree for the given $targetSettings
44+
*
45+
* Settings that have the same value as their default value
46+
* are removed (tree is minified) if the list of default settings
47+
* via $defaultSettings.
48+
*
49+
* @param array $currentSettingsTree Current settings tree (recursive structure)
50+
* In case of site settings: config/sites/…/settings.yaml
51+
* @param SettingsInterface $targetSettings Target settings
52+
* (values as supplied via the settings editor)
53+
* @param SettingsInterface $defaultSettings Default settings, without local settings tree applied.
54+
* In case of site settings: Combination of all settings
55+
* defined in settings.definitions.yaml + setting.yaml
56+
* from all selected sets combined
57+
*/
58+
public static function diff(
59+
array $currentSettingsTree,
60+
SettingsInterface $targetSettings,
61+
?SettingsInterface $defaultSettings = null,
62+
): self {
63+
// Copy existing settings from current settings tree, to keep any settings
64+
// that have been present before (and are not defined in $defaultSettings)
65+
// Usecase for site settings:
66+
// Preserve "anonymous" v12-style site settings that have no definition in settings.definitions.yaml
67+
$settingsTree = $currentSettingsTree;
68+
69+
// Merge target settings into current settingsTree
70+
$changes = [];
71+
$deletions = [];
72+
foreach ($targetSettings->getIdentifiers() as $key) {
73+
$value = $targetSettings->get($key);
74+
if ($defaultSettings !== null && $value === $defaultSettings->get($key)) {
75+
if (ArrayUtility::isValidPath($settingsTree, $key, '.')) {
76+
$settingsTree = self::removeByPathWithAncestors($settingsTree, $key, '.');
77+
$deletions[] = $key;
78+
}
79+
continue;
80+
}
81+
if (!ArrayUtility::isValidPath($settingsTree, $key, '.') ||
82+
$value !== ArrayUtility::getValueByPath($settingsTree, $key, '.')
83+
) {
84+
$settingsTree = ArrayUtility::setValueByPath($settingsTree, $key, $value, '.');
85+
$changes[] = $key;
86+
}
87+
}
88+
89+
return new self(
90+
$settingsTree,
91+
$changes,
92+
$deletions
93+
);
94+
}
95+
96+
private static function removeByPathWithAncestors(array $array, string $path, string $delimiter): array
97+
{
98+
if ($path === '' || !ArrayUtility::isValidPath($array, $path, $delimiter)) {
99+
return $array;
100+
}
101+
102+
$array = ArrayUtility::removeByPath($array, $path, $delimiter);
103+
$parts = explode($delimiter, $path);
104+
array_pop($parts);
105+
$parentPath = implode($delimiter, $parts);
106+
107+
if ($parentPath !== '' && ArrayUtility::isValidPath($array, $parentPath, $delimiter)) {
108+
$parent = ArrayUtility::getValueByPath($array, $parentPath, $delimiter);
109+
if ($parent === []) {
110+
return self::removeByPathWithAncestors($array, $parentPath, $delimiter);
111+
}
112+
}
113+
return $array;
114+
}
115+
}

typo3/sysext/core/Classes/Site/SiteSettingsFactory.php

Lines changed: 50 additions & 16 deletions
Original file line numberDiff line numberDiff line change
@@ -68,6 +68,21 @@ public function getSettings(string $siteIdentifier, array $siteConfiguration): S
6868
return $settings;
6969
}
7070

71+
/**
72+
* Load settings from config/sites/{$siteIdentifier}/settings.yaml.
73+
*
74+
* @internal
75+
*/
76+
public function loadLocalSettingsTree(string $siteIdentifier): ?array
77+
{
78+
$fileName = $this->configPath . '/' . $siteIdentifier . '/' . $this->settingsFileName;
79+
if (!file_exists($fileName)) {
80+
return null;
81+
}
82+
83+
return $this->yamlFileLoader->load(GeneralUtility::fixWindowsFilePath($fileName));
84+
}
85+
7186
/**
7287
* Fetch the settings for a specific site and return the parsed Site Settings object.
7388
*
@@ -82,12 +97,7 @@ public function createSettings(array $sets = [], ?string $siteIdentifier = null,
8297
{
8398
$settingsTree = [];
8499
if ($siteIdentifier !== null) {
85-
$fileName = $this->configPath . '/' . $siteIdentifier . '/' . $this->settingsFileName;
86-
if (file_exists($fileName)) {
87-
$settingsTree = $this->yamlFileLoader->load(GeneralUtility::fixWindowsFilePath($fileName));
88-
} else {
89-
$settingsTree = $inlineSettings;
90-
}
100+
$settingsTree = $this->loadLocalSettingsTree($siteIdentifier) ?? $inlineSettings;
91101
}
92102

93103
return $this->composeSettings($settingsTree, $sets);
@@ -135,14 +145,12 @@ public function composeSettings(array $settingsTree, array $sets): SiteSettings
135145
);
136146
}
137147

148+
/**
149+
* @internal
150+
*/
138151
public function createSettingsForKeys(array $settingKeys, string $siteIdentifier, array $inlineSettings = []): SiteSettings
139152
{
140-
$fileName = $this->configPath . '/' . $siteIdentifier . '/' . $this->settingsFileName;
141-
if (file_exists($fileName)) {
142-
$settingsTree = $this->yamlFileLoader->load(GeneralUtility::fixWindowsFilePath($fileName));
143-
} else {
144-
$settingsTree = $inlineSettings;
145-
}
153+
$settingsTree = $this->loadLocalSettingsTree($siteIdentifier) ?? $inlineSettings;
146154

147155
/** @var array<string, string|int|float|bool|array|null> $settingsMap */
148156
$settingsMap = [];
@@ -152,11 +160,37 @@ public function createSettingsForKeys(array $settingKeys, string $siteIdentifier
152160
}
153161
$settingsMap[$key] = ArrayUtility::getValueByPath($settingsTree, $key, '.');
154162
}
155-
$flatSettings = $settingsTree === [] ? [] : ArrayUtility::flattenPlain($settingsTree);
156-
return new SiteSettings(
157-
settings: $settingsMap,
163+
164+
return SiteSettings::create(
165+
settingsMap: $settingsMap,
166+
settingsTree: $settingsTree,
167+
);
168+
}
169+
170+
/**
171+
* @internal
172+
*/
173+
public function createSettingsFromFormData(array $settingsMap, array $definitions): SiteSettings
174+
{
175+
$settingsTree = [];
176+
foreach ($settingsMap as $key => $value) {
177+
$definition = $definitions[$key] ?? null;
178+
if ($definition === null) {
179+
throw new \RuntimeException('Unexpected setting ' . $key . ' is not defined', 1724067004);
180+
}
181+
$type = $this->settingsTypeRegistry->get($definition->type);
182+
// @todo We should collect invalid values (readonly-violation/validation-error) and report in the UI instead of ignoring them
183+
if ($definition->readonly || !$type->validate($value, $definition)) {
184+
$value = $definition->default;
185+
}
186+
$value = $type->transformValue($value, $definition);
187+
$settingsMap[$key] = $value;
188+
$settingsTree = ArrayUtility::setValueByPath($settingsTree, $key, $value, '.');
189+
}
190+
191+
return SiteSettings::create(
192+
settingsMap: $settingsMap,
158193
settingsTree: $settingsTree,
159-
flatSettings: $flatSettings,
160194
);
161195
}
162196

typo3/sysext/core/Classes/Site/SiteSettingsService.php

Lines changed: 17 additions & 69 deletions
Original file line numberDiff line numberDiff line change
@@ -24,12 +24,12 @@
2424
use TYPO3\CMS\Core\Messaging\FlashMessage;
2525
use TYPO3\CMS\Core\Messaging\FlashMessageService;
2626
use TYPO3\CMS\Core\Settings\SettingDefinition;
27+
use TYPO3\CMS\Core\Settings\SettingsTree;
2728
use TYPO3\CMS\Core\Settings\SettingsTypeRegistry;
2829
use TYPO3\CMS\Core\Site\Entity\Site;
2930
use TYPO3\CMS\Core\Site\Entity\SiteSettings;
3031
use TYPO3\CMS\Core\Site\Set\SetRegistry;
3132
use TYPO3\CMS\Core\Type\ContextualFeedbackSeverity;
32-
use TYPO3\CMS\Core\Utility\ArrayUtility;
3333
use TYPO3\CMS\Core\Utility\GeneralUtility;
3434

3535
/**
@@ -78,57 +78,20 @@ public function getLocalSettings(Site $site): SiteSettings
7878
);
7979
}
8080

81-
public function computeSettingsDiff(Site $site, array $rawSettings, bool $minify = true): array
81+
public function computeSettingsDiff(Site $site, SiteSettings $newSettings, bool $minify = true): SettingsTree
8282
{
83-
$settings = [];
84-
$localSettings = [];
83+
// Settings from sets only – setting values without site-local config/sites/*/settings.yaml applied
84+
$defaultSettings = $minify ? $this->siteSettingsFactory->createSettings($site->getSets(), null) : null;
8585

86-
$definitions = $this->getDefinitions($site);
87-
foreach ($rawSettings as $key => $value) {
88-
$definition = $definitions[$key] ?? null;
89-
if ($definition === null) {
90-
throw new \RuntimeException('Unexpected setting ' . $key . ' is not defined', 1724067004);
91-
}
92-
if ($definition->readonly) {
93-
continue;
94-
}
95-
$type = $this->settingsTypeRegistry->get($definition->type);
96-
$settings[$key] = $type->transformValue($value, $definition);
97-
}
98-
99-
// Settings from sets – setting values without config/sites/*/settings.yaml applied
100-
$setSettings = $this->siteSettingsFactory->createSettings($site->getSets());
10186
// Settings from config/sites/*/settings.yaml only (our persistence target)
102-
$localSettings = $this->siteSettingsFactory->createSettingsForKeys(
103-
array_map(static fn(SettingDefinition $d) => $d->key, $definitions),
104-
$site->getIdentifier(),
105-
$site->getRawConfiguration()['settings'] ?? []
106-
);
107-
108-
// Read existing settings, as we *must* not remove any settings that may be present because of
109-
// * "undefined" settings that were supported since TYPO3 v12
110-
// * (temporary) inactive sets
111-
$settingsTree = $localSettings->getAll();
87+
$localSettings = $this->siteSettingsFactory->loadLocalSettingsTree($site->getIdentifier()) ??
88+
$site->getRawConfiguration()['settings'] ?? [];
11289

113-
// Merge incoming settings into current settingsTree
114-
$changes = [];
115-
$deletions = [];
116-
foreach ($settings as $key => $value) {
117-
if ($minify && $value === $setSettings->get($key)) {
118-
if (ArrayUtility::isValidPath($settingsTree, $key, '.')) {
119-
$settingsTree = $this->removeByPathWithAncestors($settingsTree, $key, '.');
120-
$deletions[] = $key;
121-
}
122-
continue;
123-
}
124-
$settingsTree = ArrayUtility::setValueByPath($settingsTree, $key, $value, '.');
125-
$changes[] = $key;
126-
}
127-
return [
128-
'settings' => $settingsTree,
129-
'changes' => $changes,
130-
'deletions' => $deletions,
131-
];
90+
return SettingsTree::diff(
91+
$localSettings,
92+
$newSettings,
93+
$defaultSettings,
94+
);
13295
}
13396

13497
public function writeSettings(Site $site, array $settings): void
@@ -144,7 +107,10 @@ public function writeSettings(Site $site, array $settings): void
144107
$this->codeCache->flush();
145108
}
146109

147-
private function getDefinitions(Site $site): array
110+
/**
111+
* @return array<string, SettingDefinition>
112+
*/
113+
public function getDefinitions(Site $site): array
148114
{
149115
$sets = $this->setRegistry->getSets(...$site->getSets());
150116
$definitions = [];
@@ -156,26 +122,8 @@ private function getDefinitions(Site $site): array
156122
return $definitions;
157123
}
158124

159-
private function removeByPathWithAncestors(array $array, string $path, string $delimiter): array
125+
public function createSettingsFromFormData(Site $site, array $settingsMap): SiteSettings
160126
{
161-
if ($path === '') {
162-
return $array;
163-
}
164-
if (!ArrayUtility::isValidPath($array, $path, $delimiter)) {
165-
return $array;
166-
}
167-
168-
$array = ArrayUtility::removeByPath($array, $path, $delimiter);
169-
$parts = explode($delimiter, $path);
170-
array_pop($parts);
171-
$parentPath = implode($delimiter, $parts);
172-
173-
if ($parentPath !== '' && ArrayUtility::isValidPath($array, $parentPath, $delimiter)) {
174-
$parent = ArrayUtility::getValueByPath($array, $parentPath, $delimiter);
175-
if ($parent === []) {
176-
return $this->removeByPathWithAncestors($array, $parentPath, $delimiter);
177-
}
178-
}
179-
return $array;
127+
return $this->siteSettingsFactory->createSettingsFromFormData($settingsMap, $this->getDefinitions($site));
180128
}
181129
}

0 commit comments

Comments
 (0)