diff --git a/CHANGELOG.md b/CHANGELOG.md index 01e808adaee..8eeb4a2578f 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -19,6 +19,9 @@ - Removed `CraftCms\Cms\Element\Conditions\Contracts\ElementConditionRuleInterface::getExclusiveQueryParams()` and `modifyQuery()`. `ElementQueryConditionRuleInterface::modifyQuery()` should be implemented instead, which now accepts the underlying query builder directly. ([#19563](https://github.com/craftcms/cms/pull/19563)) - Removed `CraftCms\Cms\Element\Conditions\ElementCondition::$queryParams`. ([#19563](https://github.com/craftcms/cms/pull/19563)) - Remove `CraftCms\Cms\Contracts\PluginInterface::createSettingsModel()`. Plugins must now declare `createSettings()`. ([#19574](https://github.com/craftcms/cms/pull/19574)) +- Replaced the project config implementation with separate change handling, storage, and rebuild components. +- Added `CraftCms\Cms\ProjectConfig\ProjectConfig::getPendingChanges()`. +- Fixed a bug where removing false, zero, or empty-string project config values could leave their database rows behind. - Fixed an error that could occur when creating relation fields. ([#19571](https://github.com/craftcms/cms/pull/19571)) - Fixed a bug where failed structure moves could leave locks held and block subsequent operations. ([#19568](https://github.com/craftcms/cms/pull/19568)) - Fixed a bug where structure repair previews could differ from the repairs that would be applied. ([#19568](https://github.com/craftcms/cms/pull/19568)) diff --git a/src/ProjectConfig/ChangeHandlers.php b/src/ProjectConfig/ChangeHandlers.php new file mode 100644 index 00000000000..679464bed36 --- /dev/null +++ b/src/ProjectConfig/ChangeHandlers.php @@ -0,0 +1,102 @@ + */ + private array $handlers = []; + + /** @var list */ + private array $deferred = []; + + public function register(string $event, string $path, callable $handler, mixed $data): void + { + $pattern = str_replace('\{uid\}', '('.ProjectConfig::UID_PATTERN.')', preg_quote($path, '~')); + + $this->handlers[] = [ + 'event' => $event, + 'pattern' => '~^('.$pattern.')(?:\.|$)~', + 'depth' => ProjectConfigHelper::pathDepth($path), + 'handler' => $handler, + 'data' => $data, + ]; + + // Make sure the event handlers are sorted from least-to-most specific. + usort($this->handlers, fn (array $a, array $b): int => $a['depth'] <=> $b['depth']); + } + + /** @param Closure(string, ConfigEvent): void $parentEvent */ + public function dispatch(ConfigEvent $event, Closure $parentEvent): void + { + foreach ($this->handlers as $registration) { + if (! $event instanceof $registration['event']) { + continue; + } + + if (! preg_match($registration['pattern'], $event->path, $matches)) { + continue; + } + + $path = $matches[1]; + + if ($path !== $event->path) { + $parentEvent($path, $event); + + continue; + } + + $notification = $event; + + // Skip the full match and containing path, leaving only the {uid} captures. + $notification->tokenMatches = array_slice($matches, 2); + $notification->data = $registration['data']; + + try { + ($registration['handler'])($notification); + } finally { + $notification->tokenMatches = null; + $notification->data = null; + } + } + } + + public function defer(ConfigEvent $event, callable $handler): void + { + $this->deferred[] = ['event' => clone $event, 'handler' => $handler]; + } + + public function runDeferred(int $maxDefers): void + { + $remaining = count($this->deferred) + $maxDefers; + + while ($this->deferred !== []) { + if ($remaining-- <= 0) { + $paths = array_unique(array_map(fn (array $item): string => $item['event']->path, $this->deferred)); + + throw new OperationAbortedException('Unable to resolve deferred project config paths: '.implode(', ', $paths)); + } + + ['event' => $event, 'handler' => $handler] = array_shift($this->deferred); + + try { + $handler($event); + } finally { + $event->tokenMatches = null; + $event->data = null; + } + } + } + + public function reset(): void + { + $this->deferred = []; + } +} diff --git a/src/ProjectConfig/ConfigChanges.php b/src/ProjectConfig/ConfigChanges.php new file mode 100644 index 00000000000..b46e21da516 --- /dev/null +++ b/src/ProjectConfig/ConfigChanges.php @@ -0,0 +1,96 @@ + */ + public static function leaves(mixed $value, string $path = ''): array + { + if (! is_array($value)) { + return $value === null ? [] : [$path => $value]; + } + + $leaves = []; + ProjectConfigHelper::flattenConfigArray($value, $path, $leaves); + + return $leaves; + } + + /** @return array{added?: array, removed?: array, message?: string} */ + public static function history(string $path, mixed $oldValue, mixed $newValue, ?string $message): array + { + $change = []; + + if (ProjectConfigHelper::encodeValueAsString($oldValue) !== ProjectConfigHelper::encodeValueAsString($newValue)) { + if ($newValue !== null) { + $change['added'] = self::leaves($newValue, $path); + } + + if ($oldValue !== null) { + $change['removed'] = self::leaves($oldValue, $path); + } + } + + if ($message !== null && $message !== '') { + $change['message'] = $message; + } + + return $change; + } + + /** + * @param array $old + * @param array $new + * @return array{newItems: list, removedItems: list, changedItems: list} + */ + public static function pending(array $old, array $new, bool $force = false): array + { + $old = self::leaves($old); + $new = self::leaves($new); + $paths = ['newItems' => [], 'removedItems' => [], 'changedItems' => []]; + + foreach (array_unique([...array_keys($old), ...array_keys($new)]) as $path) { + $category = match (true) { + ! array_key_exists($path, $old) => 'newItems', + ! array_key_exists($path, $new) => 'removedItems', + $force || $old[$path] !== $new[$path] => 'changedItems', + default => null, + }; + + if ($category !== null) { + // Changes to leaf values are processed at their immediate parent path. + $parent = ProjectConfigHelper::pathWithoutLastSegment($path); + $paths[$category][] = $parent ?? $path; + } + } + + foreach ($paths as &$category) { + $category = array_values(array_unique($category)); + + // Group paths by similarity, sorted by depth (descending), e.g.: + // - foo1.bar.baz + // - foo1.bar + // - foo2.bar.baz + // - foo2.bar + usort($category, function (string $left, string $right): int { + $leftSegments = ProjectConfigHelper::pathSegments($left); + $rightSegments = ProjectConfigHelper::pathSegments($right); + $sharedDepth = min(count($leftSegments), count($rightSegments)); + + for ($index = 0; $index < $sharedDepth; $index++) { + if ($leftSegments[$index] !== $rightSegments[$index]) { + return $leftSegments[$index] <=> $rightSegments[$index]; + } + } + + return count($rightSegments) <=> count($leftSegments); + }); + } + + return $paths; + } +} diff --git a/src/ProjectConfig/ConfigRebuilder.php b/src/ProjectConfig/ConfigRebuilder.php new file mode 100644 index 00000000000..c61ec54ba1e --- /dev/null +++ b/src/ProjectConfig/ConfigRebuilder.php @@ -0,0 +1,128 @@ + $config + * @return array + */ + public function build(array $config): array + { + unset($config[ProjectConfig::PATH_META]); + $config[ProjectConfig::PATH_DATE_MODIFIED] = now()->getTimestamp(); + $config[ProjectConfig::PATH_SYSTEM]['schemaVersion'] = Info::fetch()->schemaVersion; + $config[ProjectConfig::PATH_ADDRESSES] = $this->fieldLayout(Address::class); + $config[ProjectConfig::PATH_ASSET_TRANSFORMERS] = $this->components($this->assetTransformers->getAllAssetTransformers()); + $config[ProjectConfig::PATH_ENTRY_TYPES] = $this->components($this->entryTypes->getAllEntryTypes()); + $config[ProjectConfig::PATH_FIELDS] = collect($this->fields->getAllFields('global')) + ->mapWithKeys(fn ($field): array => [$field->uid => $this->fields->createFieldConfig($field)]) + ->all(); + $config[ProjectConfig::PATH_FS] = collect($this->filesystems->getAllFilesystems()) + ->mapWithKeys(fn ($filesystem): array => [$filesystem->handle => $this->filesystems->createFilesystemConfig($filesystem)]) + ->all(); + $config[ProjectConfig::PATH_IMAGE_TRANSFORMS] = $this->components($this->imageTransforms->getAllTransforms()); + $config[ProjectConfig::PATH_SECTIONS] = $this->components($this->sections->getAllSections()); + $config[ProjectConfig::PATH_SITES] = $this->components($this->sites->getAllSites(true)); + $config[ProjectConfig::PATH_SITE_GROUPS] = $this->components($this->siteGroups->getAllGroups()); + $config[ProjectConfig::PATH_VOLUMES] = $this->components($this->volumes->getAllVolumes()); + $config[ProjectConfig::PATH_USERS]['groups'] = $this->components($this->userGroups->getAllGroups()); + unset($config[ProjectConfig::PATH_USERS]['fieldLayouts']); + $config[ProjectConfig::PATH_USERS] = array_replace($config[ProjectConfig::PATH_USERS], $this->fieldLayout(User::class)); + + $token = $this->gql->getPublicToken(); + $config[ProjectConfig::PATH_GRAPHQL] = [ + 'schemas' => $this->components($this->gql->getSchemas()), + 'publicToken' => [ + 'enabled' => $token->enabled ?? false, + 'expiryDate' => $token?->expiryDate?->getTimestamp(), + ], + ]; + + $plugins = $config[ProjectConfig::PATH_PLUGINS] ?? []; + $config[ProjectConfig::PATH_PLUGINS] = []; + + foreach ($this->database->table(Table::PLUGINS)->get(['handle', 'schemaVersion']) as $plugin) { + $config[ProjectConfig::PATH_PLUGINS][$plugin->handle] = array_replace($plugins[$plugin->handle] ?? [], ['schemaVersion' => $plugin->schemaVersion]); + } + + $config[ProjectConfig::PATH_ELEMENT_SOURCES] ??= []; + + foreach ($config[ProjectConfig::PATH_ELEMENT_SOURCES] as &$sources) { + foreach ($sources as &$source) { + if (($source['type'] ?? null) !== ElementSourceTypes::TYPE_CUSTOM || empty($source['condition'])) { + continue; + } + + try { + $source['condition'] = $this->conditions->createCondition($source['condition'])->getConfig(); + } catch (InvalidArgumentException|RuntimeException) { + // Preserve conditions whose plugin is currently unavailable. + } + } + unset($source); + } + unset($sources); + + return $config; + } + + /** + * @param iterable $components + * @return array + */ + private function components(iterable $components): array + { + return collect($components)->mapWithKeys(fn ($component): array => [$component->uid => $component->getConfig()])->all(); + } + + /** @return array{fieldLayouts?: array} */ + private function fieldLayout(string $elementType): array + { + $layout = $this->fields->getLayoutByType($elementType, false); + $config = $layout?->getConfig(); + + return $config ? ['fieldLayouts' => [$layout->uid => $config]] : []; + } +} diff --git a/src/ProjectConfig/ConfigStorage.php b/src/ProjectConfig/ConfigStorage.php new file mode 100644 index 00000000000..44040f7ee3f --- /dev/null +++ b/src/ProjectConfig/ConfigStorage.php @@ -0,0 +1,265 @@ + Info::fetch()->configVersion); + } + + /** @return array */ + public function readDatabase(?int $cacheDuration): array + { + if (! Cms::isInstalled()) { + return []; + } + + return DependencyCache::remember(ProjectConfig::STORED_CACHE_KEY, $cacheDuration, function (): array { + $data = []; + + // Paths only need parent-before-child ordering, not locale-aware sorting. + $orderBy = DB::connection()->isPgsql() ? new Expression('path COLLATE "C"') : 'path'; + + foreach (DB::table(Table::PROJECTCONFIG)->orderBy($orderBy)->pluck('value', 'path') as $path => $value) { + $value = Json::decode(Str::decdec($value)); + $segments = ProjectConfigHelper::pathSegments($path); + $cursor = &$data; + + foreach ($segments as $segment) { + // Older config data can contain a scalar at a path that also has descendants. + if (! is_array($cursor)) { + $cursor = []; + } + + $cursor[$segment] ??= []; + $cursor = &$cursor[$segment]; + } + + $cursor = $value; + unset($cursor); + } + + return ProjectConfigHelper::cleanupConfig($data); + }, $this->dependency()); + } + + /** @param list, removed?: array, message?: string}> $changes */ + public function save(array $changes): void + { + DB::transaction(function () use ($changes): void { + foreach ($changes as $change) { + $remove = array_keys($change['removed'] ?? []); + $rows = []; + + foreach ($change['added'] ?? [] as $path => $value) { + $parent = $path; + + // Delete parent keys, as they cannot hold a value AND be an array at the same time. + while (($parent = ProjectConfigHelper::pathWithoutLastSegment($parent)) !== null) { + $remove[] = $parent; + } + + $encoded = ProjectConfigHelper::encodeValueAsString($value); + + if (DB::isMysql() && preg_match('/[\x{10000}-\x{10FFFF}]/u', $encoded)) { + $encoded = 'base64:'.base64_encode($encoded); + } + + $rows[] = ['path' => $path, 'value' => $encoded]; + } + + foreach (array_chunk(array_unique($remove), 500) as $paths) { + DB::table(Table::PROJECTCONFIG)->whereIn('path', $paths)->delete(); + } + + foreach (array_chunk($rows, 500) as $batch) { + DB::table(Table::PROJECTCONFIG)->upsert($batch, ['path'], ['value']); + } + } + + $this->invalidate(); + }); + } + + public function invalidate(): void + { + Info::fetch()->update(['configVersion' => Str::random(12)]); + Cache::forget(ProjectConfig::STORED_CACHE_KEY); + Cache::forget(ProjectConfig::DIFF_CACHE_KEY); + } + + public function directory(string $folder): string + { + return Path::config($folder); + } + + public function exists(string $folder): bool + { + return File::isFile($this->directory($folder).'/'.ProjectConfig::CONFIG_FILENAME); + } + + /** @return array|null */ + public function readYaml(string $folder): ?array + { + if (! $this->exists($folder)) { + return null; + } + + $data = []; + $files = new Finder() + ->files() + ->in($this->directory($folder)) + ->ignoreDotFiles(false) + ->ignoreVCS(false) + ->name('/\.yaml$/i') + ->sortByName(); + + foreach ($files as $file) { + $value = Yaml::parseFile($file->getPathname()) ?? []; + + if (! is_array($value)) { + throw new RuntimeException("Project config file {$file->getPathname()} must contain a mapping."); + } + + $segments = $file->getRelativePath() === '' ? [] : explode(DIRECTORY_SEPARATOR, $file->getRelativePath()); + $name = preg_replace('/\.yaml$/i', '', $file->getFilename()); + // In -- filenames, only the UID belongs to the config path. + $name = preg_replace('/^\w+--(?='.Str::uuidPattern().'$)/', '', $name); + + if ($segments !== [] && $name !== end($segments)) { + $segments[] = $name; + } + + $cursor = &$data; + + foreach ($segments as $segment) { + $cursor[$segment] ??= []; + $cursor = &$cursor[$segment]; + } + + $cursor = array_replace_recursive($cursor, $value); + unset($cursor); + } + + return $data; + } + + /** @param array $data */ + public function writeYaml(string $folder, array $data): void + { + $directory = $this->directory($folder); + + try { + File::makeDirectory($directory); + $this->clearVisibleFiles($directory); + $names = $data['meta']['__names__'] ?? []; + + foreach (ProjectConfigHelper::splitConfigIntoComponents($data) as $filename => $component) { + $yaml = Yaml::dump(ProjectConfigHelper::cleanupConfig($component), 20, 2, Yaml::DUMP_COMPACT_NESTED_MAPPING); + $yaml = preg_replace_callback('/^.*?('.Str::uuidPattern().').*$/m', function (array $line) use ($names): string { + $name = trim(str_replace(["\r", "\n"], ' ', $names[$line[1]] ?? '')); + + return $name === '' ? $line[0] : $line[0].' # '.$name; + }, $yaml); + File::writeToFile($directory.'/'.$filename, $yaml); + } + } catch (Throwable $exception) { + Cache::put(ProjectConfig::FILE_ISSUES_CACHE_KEY, true, ProjectConfig::CACHE_DURATION); + + // Remove any files already written so Craft cannot apply an incomplete config. + try { + $this->clearVisibleFiles($directory); + } catch (Throwable $cleanupException) { + report($cleanupException); + } + + throw new Exception('Unable to write new project config files', 0, $exception); + } + + Cache::forget(ProjectConfig::FILE_ISSUES_CACHE_KEY); + event(new YamlFilesWritten); + } + + public function updateParsedTime(string $folder): bool + { + $file = $this->directory($folder).'/'.ProjectConfig::CONFIG_FILENAME; + clearstatcache(true, $file); + + return Cache::put(ProjectConfig::CACHE_KEY, File::exists($file) ? File::lastModified($file) : 0, ProjectConfig::CACHE_DURATION); + } + + public function parsedTimeMatches(string $folder): bool + { + $file = $this->directory($folder).'/'.ProjectConfig::CONFIG_FILENAME; + clearstatcache(true, $file); + + return Cache::get(ProjectConfig::CACHE_KEY) === (File::exists($file) ? File::lastModified($file) : 0); + } + + /** @param list, removed?: array, message?: string}> $changes */ + public function writeDelta(array $changes, int $maxDeltas): void + { + $entries = []; + + foreach ($changes as $change) { + $entry = array_filter($change, fn (mixed $value): bool => $value !== []); + + foreach (array_intersect_key($change['added'] ?? [], $change['removed'] ?? []) as $path => $value) { + $old = $change['removed'][$path]; + $comparableOld = is_bool($old) ? (int) $old : $old; + $comparableNew = is_bool($value) ? (int) $value : $value; + + if ($comparableOld !== $comparableNew) { + $entry['changed'][$path] = ['from' => $old, 'to' => $value]; + } + + unset($entry['added'][$path], $entry['removed'][$path]); + } + + $entry = array_filter($entry, fn (mixed $value): bool => $value !== []); + + if ($entry !== []) { + $entries[] = $entry; + } + } + + if ($entries === []) { + return; + } + + $path = Path::configDelta(ProjectConfig::CONFIG_DELTA_FILENAME); + File::cycle($path, $maxDeltas); + File::writeToFile($path, Yaml::dump(['dateApplied' => now()->format('Y-m-d H:i:s'), 'changes' => $entries], 20, 2)); + } + + private function clearVisibleFiles(string $directory): void + { + if (! File::cleanDirectory($directory, except: ['.*'])) { + throw new RuntimeException("Unable to clear $directory."); + } + } +} diff --git a/src/ProjectConfig/ProjectConfig.php b/src/ProjectConfig/ProjectConfig.php index f15f8b0abb1..20715ea4f63 100644 --- a/src/ProjectConfig/ProjectConfig.php +++ b/src/ProjectConfig/ProjectConfig.php @@ -4,74 +4,33 @@ namespace CraftCms\Cms\ProjectConfig; -use CraftCms\Cms\Address\Elements\Address; -use CraftCms\Cms\Asset\AssetTransformers; -use CraftCms\Cms\Asset\Data\AssetTransformer; -use CraftCms\Cms\Asset\Data\Volume; use CraftCms\Cms\Cms; use CraftCms\Cms\Config\GeneralConfig; use CraftCms\Cms\Database\Table; -use CraftCms\Cms\Element\ElementSources; -use CraftCms\Cms\Entry\Data\EntryType; -use CraftCms\Cms\Field\Contracts\FieldInterface; -use CraftCms\Cms\Filesystem\Contracts\FsInterface; -use CraftCms\Cms\Image\Data\ImageTransform; -use CraftCms\Cms\Image\ImageTransforms; use CraftCms\Cms\Plugin\Plugins; -use CraftCms\Cms\ProjectConfig\Data\ProjectConfigData; -use CraftCms\Cms\ProjectConfig\Data\ReadOnlyProjectConfigData; use CraftCms\Cms\ProjectConfig\Events\ChangesApplied; use CraftCms\Cms\ProjectConfig\Events\ConfigEvent; use CraftCms\Cms\ProjectConfig\Events\ItemAdded; use CraftCms\Cms\ProjectConfig\Events\ItemRemoved; use CraftCms\Cms\ProjectConfig\Events\ItemUpdated; +use CraftCms\Cms\ProjectConfig\Events\ProjectConfigItemAdding; +use CraftCms\Cms\ProjectConfig\Events\ProjectConfigItemRemoved; +use CraftCms\Cms\ProjectConfig\Events\ProjectConfigItemUpdated; use CraftCms\Cms\ProjectConfig\Events\ProjectConfigRebuilt; -use CraftCms\Cms\ProjectConfig\Events\YamlFilesWritten; use CraftCms\Cms\ProjectConfig\Exceptions\BusyResourceException; use CraftCms\Cms\ProjectConfig\Exceptions\ReadonlyException; use CraftCms\Cms\ProjectConfig\Exceptions\StaleResourceException; -use CraftCms\Cms\Section\Data\Section; -use CraftCms\Cms\Shared\Exceptions\NotSupportedException; -use CraftCms\Cms\Shared\Exceptions\OperationAbortedException; use CraftCms\Cms\Shared\Models\Info; -use CraftCms\Cms\Site\Data\Site; -use CraftCms\Cms\Site\Data\SiteGroup; -use CraftCms\Cms\Support\Facades\Conditions; -use CraftCms\Cms\Support\Facades\EntryTypes; -use CraftCms\Cms\Support\Facades\Fields; -use CraftCms\Cms\Support\Facades\Filesystems; -use CraftCms\Cms\Support\Facades\Gql; -use CraftCms\Cms\Support\Facades\Path; -use CraftCms\Cms\Support\Facades\Sections; -use CraftCms\Cms\Support\Facades\SiteGroups; -use CraftCms\Cms\Support\Facades\Sites; -use CraftCms\Cms\Support\Facades\UserGroups; -use CraftCms\Cms\Support\Facades\Volumes; -use CraftCms\Cms\Support\File; -use CraftCms\Cms\Support\Json; use CraftCms\Cms\Support\Str; -use CraftCms\Cms\User\Elements\User; use CraftCms\DependencyAwareCache\Dependency\CallbackDependency; -use CraftCms\DependencyAwareCache\Facades\DependencyCache; -use Exception; use Illuminate\Container\Attributes\Singleton; use Illuminate\Contracts\Cache\Lock; -use Illuminate\Database\Query\Expression; -use Illuminate\Support\Collection; use Illuminate\Support\Facades\Cache; -use Illuminate\Support\Facades\Date; use Illuminate\Support\Facades\DB; use Illuminate\Support\Facades\Event; -use Illuminate\Support\Facades\Log; -use InvalidArgumentException; -use RuntimeException; -use SplFileInfo; -use Symfony\Component\Finder\Finder; -use Symfony\Component\Yaml\Yaml; +use PDOException; use Throwable; -use function Illuminate\Filesystem\join_paths; - #[Singleton] class ProjectConfig { @@ -96,7 +55,7 @@ class ProjectConfig public const string DIFF_CACHE_KEY = 'projectConfig:diff'; /** - * The duration that project config caches should be cached. + * The duration that project config caches should be cached, in seconds. Defaults to one year. */ public const int CACHE_DURATION = 60 * 60 * 24 * 365; // 1 year @@ -116,11 +75,16 @@ class ProjectConfig public const string ASSOC_KEY = '__assoc__'; /** - * @see _acquireLock() - * @see _releaseLock() + * @see acquireLock() + * @see releaseLock() */ public const string MUTEX_NAME = 'project-config'; + /** + * Pattern for `{uid}` tokens, which match UUIDs as well as component handles. + */ + public const string UID_PATTERN = '[a-zA-Z0-9_-]+'; + public const string PATH_ADDRESSES = 'addresses'; public const string PATH_ADDRESS_FIELD_LAYOUTS = self::PATH_ADDRESSES.'.'.'fieldLayouts'; @@ -173,18 +137,10 @@ class ProjectConfig public const string PATH_VOLUMES = 'volumes'; - // Regexp patterns - // ------------------------------------------------------------------------- - - /** - * Regexp pattern to determine a string that could be used as an UID. - */ - public const string UID_PATTERN = '[a-zA-Z0-9_-]+'; - /** * @var bool Whether project config changes should be written to YAML files automatically. * - * If set to `false`, you can manually write out project config YAML files using the `project-config/write` command. + * If set to `false`, you can manually write out project config YAML files using the `craft:project-config/write` command. * * ::: warning * If this is set to `false`, Craft won’t have a strong grasp of whether the YAML files or database contain the most relevant @@ -209,7 +165,7 @@ class ProjectConfig * @var int The maximum number of times deferred events can be re-deferred before we give up on them * * @see defer() - * @see _applyChanges() + * @see applyConfigChanges() */ public int $maxDefers = 500; @@ -229,20 +185,10 @@ class ProjectConfig public bool $forceUpdate = false; /** - * @var string[] A list of all external files. - */ - private array $_configFileList = []; - - /** - * @var int|null The project config cache duration. If null, the config setting will be used. + * @var int|null The project config cache duration. If null, the [[GeneralConfig::cacheDuration]] config setting will be used. */ public ?int $cacheDuration = null; - /** - * @var bool Whether to write out updated YAML changes at the end of the request - */ - private bool $_updateYaml = false; - /** * @var bool Whether we’re listening for the request end, to update the config parse time caches. * @@ -255,99 +201,69 @@ class ProjectConfig */ public private(set) bool $isApplyingExternalChanges = false; - /** - * @var bool Whether the config's dateModified timestamp has been updated by this request. - */ - private bool $_timestampUpdated = false; + /** @var array|null */ + private ?array $current = null; - /** - * @var array Deferred config sync events - * - * @see defer() - * @see _applyChanges() - */ - private array $_deferredEvents = []; + /** @var array|null */ + private ?array $external = null; - /** - * A running list of all the changes applied during this request - */ - /** @var array> */ - private array $_appliedChanges = []; + /** @var array|null */ + private ?array $original = null; /** @var array */ private array $claimedPaths = []; + /** @var array */ + private array $processedPaths = []; + + /** @var list, removed?: array, message?: string}> */ + private array $changes = []; + private int $persistedChanges = 0; - /** - * @var ReadOnlyProjectConfigData|null Config as defined in the external config. - */ - private ?ReadOnlyProjectConfigData $_externalConfig = null; + private int $loggedChanges = 0; - /** - * @var ReadOnlyProjectConfigData|null Current config as stored in database. - */ - private ?ReadOnlyProjectConfigData $_internalConfig = null; + private int $generation = 0; - /** - * @var ProjectConfigData|null The currently working config - it consists of the current config plus any changes - * applied during this request. - */ - private ?ProjectConfigData $_currentWorkingConfig = null; + private bool $timestampUpdated = false; - /** - * @var array, list> Config change handlers - * - * @see registerChangeEventHandler() - * @see handleChangeEvent() - * @see _sortChangeEventHandlers() - */ - private array $_changeEventHandlers = []; + private bool $yamlDirty = false; - /** - * @var bool[] Whether the change event handlers have been sorted. - * - * @see registerChangeEventHandler() - * @see handleChangeEvent() - * @see _sortChangeEventHandlers() - */ - private array $_sortedChangeEventHandlers = []; + private ?Lock $lock = null; + + private readonly ChangeHandlers $handlers; - private ?Lock $_mutex = null; + private readonly ConfigStorage $storage; public function __construct(GeneralConfig $generalConfig) { - Event::listen(ItemAdded::class, $this->handleChangeEvent(...)); - Event::listen(ItemUpdated::class, $this->handleChangeEvent(...)); - Event::listen(ItemRemoved::class, $this->handleChangeEvent(...)); - $this->readOnly = Cms::isInstalled() && ! $generalConfig->allowAdminChanges; $this->writeYamlAutomatically = ! app()->isEphemeral(); - } - - public function writeYamlAutomatically(bool $writeYamlAutomatically = true): self - { - $this->writeYamlAutomatically = $writeYamlAutomatically; + $this->handlers = new ChangeHandlers; + $this->storage = new ConfigStorage; - return $this; + foreach ([ItemAdded::class, ItemUpdated::class, ItemRemoved::class] as $event) { + Event::listen($event, $this->handleChangeEvent(...)); + } } /** - * Saves the modified project config state and writes out updated YAML files, if needed. + * Sets whether project config changes should be written to YAML files automatically. + * + * If set to `false`, you can manually write out project config YAML files using the `craft:project-config/write` command. + * + * ::: warning + * If this is set to `false`, Craft won’t have a strong grasp of whether the YAML files or database contain the most relevant + * project config data, so there’s a chance that the Project Config utility will be a bit misleading. + * ::: + * + * @see flush() */ - public function flush(): void + public function writeYamlAutomatically(bool $writeYamlAutomatically = true): self { - $this->saveModifiedConfigData(); + $this->writeYamlAutomatically = $writeYamlAutomatically; - if ($this->writeYamlAutomatically) { - $this->writeYamlFiles(); - } + return $this; } /** @@ -357,16 +273,19 @@ public function flush(): void */ public function reset(): void { - $this->resetClaimedPaths(); - $this->_internalConfig = null; - $this->_externalConfig = null; - $this->_currentWorkingConfig = null; - $this->_configFileList = []; - $this->_updateYaml = false; - $this->_appliedChanges = []; + $this->current = null; + $this->external = null; + $this->original = null; + $this->changes = []; $this->persistedChanges = 0; + $this->loggedChanges = 0; + $this->processedPaths = []; + $this->timestampUpdated = false; + $this->yamlDirty = false; $this->isApplyingExternalChanges = false; - $this->_timestampUpdated = false; + $this->generation++; + $this->resetClaimedPaths(); + $this->handlers->reset(); } /** @@ -376,7 +295,7 @@ public function reset(): void */ public function claimPath(string $path, bool $force = false): bool { - if (isset($this->claimedPaths[$path]) || (! $force && ! $this->isApplyingExternalChanges)) { + if ((! $this->isApplyingExternalChanges && ! $force) || isset($this->claimedPaths[$path])) { return false; } @@ -401,59 +320,188 @@ public function resetClaimedPaths(): void * --- * * ```php - * $value = Craft::$app->projectConfig->get('foo.bar'); + * use CraftCms\Cms\Support\Facades\ProjectConfig; + * + * $value = ProjectConfig::get('foo.bar'); * ``` * * @param string|null $path The config item path, or `null` if the entire config should be returned - * @param bool $getFromExternalConfig whether data should be fetched from the working config instead of the loaded config. Defaults to `false`. + * @param bool $getFromExternalConfig Whether to read external YAML data instead of the working config. Defaults to `false`. * @return mixed The config item value */ public function get(?string $path = null, bool $getFromExternalConfig = false): mixed { + $this->current ??= $this->storage->readDatabase($this->cacheDuration); + $this->original ??= $this->current; + $data = $this->current; + if ($getFromExternalConfig) { - $source = $this->getExternalConfig(); - } else { - $source = $this->getCurrentWorkingConfig(); + $data = $this->getHadFileWriteIssues() + ? $this->current + : (($this->external ??= $this->storage->readYaml($this->folderName)) ?? $this->current); } - if ($path === null) { - return $source->export(); - } + return $path === null ? $data : ProjectConfigHelper::traverseDataArray($data, $path); + } + + /** @return array> */ + public function find(callable $callback, bool $fromExternalConfig = false): array + { + $matches = []; + $findMatches = function (array $data, string $path) use (&$findMatches, &$matches, $callback): void { + foreach ($data as $key => $value) { + if (! is_array($value)) { + continue; + } + + $segment = str_replace('.', '\\.', (string) $key); + $itemPath = $path === '' ? $segment : $path.'.'.$segment; + + if ($callback($value, $itemPath)) { + $matches[$itemPath] = $value; + } else { + $findMatches($value, $itemPath); + } + } + }; + $findMatches($this->get(null, $fromExternalConfig), ''); - return $source->get($path); + return $matches; } /** - * Finds all config items that pass a condition, and returns their paths and configs as key/value pairs. + * Attaches an event handler for when an item is added to the config at a given path. + * + * --- + * + * ```php + * use CraftCms\Cms\ProjectConfig\Events\ConfigEvent; + * use CraftCms\Cms\Support\Facades\ProjectConfig; + * use Illuminate\Support\Facades\DB; + * + * ProjectConfig::onAdd('foo.{uid}', function (ConfigEvent $event) { + * DB::table("example_items")->updateOrInsert( + * ["uid" => $event->tokenMatches[0]], + * $event->newValue, + * ); + * }); + * ``` * - * @param bool $fromExternalConfig whether to find config items in the external config + * @param string $path The config path pattern. Can contain `{uid}` tokens, which will be passed to the handler. + * @param callable $handler The handler method. + * @param mixed $data The data to be passed to the event handler when the event is triggered. + * When the event handler is invoked, this data can be accessed via [[ConfigEvent::data]]. + * @return self Self reference */ - /** @return array> */ - public function find(callable $callback, bool $fromExternalConfig = false): array + public function onAdd(string $path, callable $handler, mixed $data = null): self + { + $this->registerChangeEventHandler(ItemAdded::class, $path, $handler, $data); + + return $this; + } + + /** + * Attaches an event handler for when an item is updated in the config at a given path. + * + * --- + * + * ```php + * use CraftCms\Cms\ProjectConfig\Events\ConfigEvent; + * use CraftCms\Cms\Support\Facades\ProjectConfig; + * use Illuminate\Support\Facades\DB; + * + * ProjectConfig::onUpdate('foo.{uid}', function (ConfigEvent $event) { + * DB::table("example_items") + * ->where("uid", $event->tokenMatches[0]) + * ->update($event->newValue); + * }); + * ``` + * + * @param string $path The config path pattern. Can contain `{uid}` tokens, which will be passed to the handler. + * @param callable $handler The handler method. + * @param mixed $data The data to be passed to the event handler when the event is triggered. + * When the event handler is invoked, this data can be accessed via [[ConfigEvent::data]]. + * @return self Self reference + */ + public function onUpdate(string $path, callable $handler, mixed $data = null): self + { + $this->registerChangeEventHandler(ItemUpdated::class, $path, $handler, $data); + + return $this; + } + + /** + * Attaches an event handler for when an item is removed from the config at a given path. + * + * --- + * + * ```php + * use CraftCms\Cms\ProjectConfig\Events\ConfigEvent; + * use CraftCms\Cms\Support\Facades\ProjectConfig; + * use Illuminate\Support\Facades\DB; + * + * ProjectConfig::onRemove('foo.{uid}', function (ConfigEvent $event) { + * DB::table("example_items") + * ->where("uid", $event->tokenMatches[0]) + * ->delete(); + * }); + * ``` + * + * @param string $path The config path pattern. Can contain `{uid}` tokens, which will be passed to the handler. + * @param callable $handler The handler method. + * @param mixed $data The data to be passed to the event handler when the event is triggered. + * When the event handler is invoked, this data can be accessed via [[ConfigEvent::data]]. + * @return self Self reference + */ + public function onRemove(string $path, callable $handler, mixed $data = null): self { - $items = []; + $this->registerChangeEventHandler(ItemRemoved::class, $path, $handler, $data); - $this->findInternal($this->get(null, $fromExternalConfig), $callback, null, $items); + return $this; + } - return $items; + /** + * Registers a config change event listener, for a specific config path pattern. + * + * @param string $event The event name + * @param string $path The config path pattern. Can contain `{uid}` tokens, which will be passed to the handler. + * @param callable $handler The handler method. + * @param mixed $data The data to be passed to the event handler when the event is triggered. + * When the event handler is invoked, this data can be accessed via [[ConfigEvent::data]]. + */ + public function registerChangeEventHandler(string $event, string $path, callable $handler, mixed $data = null): void + { + $this->handlers->register($event, $path, $handler, $data); } /** - * @param array $config - * @param array> $items + * Handles a config change event. */ - private function findInternal(array $config, callable $callback, ?string $path, array &$items): void + public function handleChangeEvent(ConfigEvent $event): void { - foreach ($config as $key => $item) { - if (is_array($item)) { - $itemPath = sprintf('%s%s', ($path !== null) ? "$path." : '', $key); - if ($callback($item, $itemPath)) { - $items[$itemPath] = $item; - } else { - $this->findInternal($item, $callback, $itemPath, $items); - } + $this->handlers->dispatch($event, function (string $path, ConfigEvent $child): void { + if (isset($this->processedPaths[$path])) { + return; } - } + + $newValue = $this->get($path, $this->isApplyingExternalChanges); + + if (is_array($newValue)) { + // The containing value may be stale; merge the child's new value before processing it. + $relative = array_slice(ProjectConfigHelper::pathSegments($child->path), count(ProjectConfigHelper::pathSegments($path))); + ProjectConfigHelper::traverseDataArray($newValue, $relative, $child->newValue); + } + + $this->commit($path, $newValue); + }); + } + + /** + * Defers an event until all other project config changes have been processed. + */ + public function defer(ConfigEvent $event, callable $handler): void + { + $this->handlers->defer($event, $handler); } /** @@ -462,7 +510,9 @@ private function findInternal(array $config, callable $callback, ?string $path, * --- * * ```php - * Craft::$app->projectConfig->set('foo.bar', 'value'); + * use CraftCms\Cms\Support\Facades\ProjectConfig; + * + * ProjectConfig::set('foo.bar', 'value'); * ``` * * @param string $path The config item path @@ -472,69 +522,49 @@ private function findInternal(array $config, callable $callback, ?string $path, * @param bool $force Whether the update should be processed regardless of whether the value actually changed * @return bool Whether the project config was modified * - * @throws Exception - * @throws NotSupportedException if the service is set to read-only mode - * @throws RuntimeException + * @throws \Exception + * @throws ReadonlyException if the service is set to read-only mode + * @throws \RuntimeException * @throws BusyResourceException if a lock could not be acquired * @throws StaleResourceException if the loaded project config is out-of-date */ - public function set( - string $path, - mixed $value, - ?string $message = null, - bool $updateTimestamp = true, - bool $force = false, - ): bool { - if (! $this->_setInternal($path, $value, $message, $updateTimestamp, $force)) { + public function set(string $path, mixed $value, ?string $message = null, bool $updateTimestamp = true, bool $force = false): bool + { + $value = is_array($value) ? ProjectConfigHelper::cleanupConfig($value) : $value; + $oldValue = $this->get($path); + + if (! $force && $oldValue === $value) { return false; } - $this->_saveConfigAfterRequest(); - - return true; - } - - private function _setInternal( - string $path, - mixed $value, - ?string $message = null, - bool $updateTimestamp = true, - bool $force = false, - ): bool { - if (is_array($value)) { - $value = ProjectConfigHelper::cleanupConfig($value); + if ($this->isApplyingExternalChanges && $value === $this->get($path, true)) { + // This value is already coming in through the external config being applied. + return true; } - $workingConfig = $this->getCurrentWorkingConfig(); - $previousValue = $workingConfig->get($path); - $valueHasChanged = $value !== $previousValue; + if ($this->readOnly && $oldValue !== $value) { + throw new ReadonlyException('Project config cannot be changed while in read-only mode.'); + } - if (! $valueHasChanged && ! $force) { - return false; + if ($oldValue !== $value) { + $this->acquireLock(); } - if ($this->readOnly && $valueHasChanged) { - // If we're applying yaml changes that are coming in via external config, anyway, bail silently. - if ($this->isApplyingExternalChanges && $value === $this->getExternalConfig()->get($path)) { - return true; - } + try { + $this->commit($path, $value, $message, $force); - throw new ReadonlyException('Changes to the project config are not possible while in read-only mode.'); - } + if ($updateTimestamp && ! $this->timestampUpdated && $oldValue !== $value) { + $this->timestampUpdated = true; + $this->commit(self::PATH_DATE_MODIFIED, now()->getTimestamp()); + } - if ($updateTimestamp && ! $this->_timestampUpdated && $valueHasChanged) { - $this->_timestampUpdated = true; - $this->_setInternal(self::PATH_DATE_MODIFIED, now()->getTimestamp(), - 'Update timestamp for project config', false, false); - } + $this->yamlDirty = true; + } catch (Throwable $exception) { + $this->releaseLock(); - if ($valueHasChanged) { - $this->_acquireLock(); + throw $exception; } - $this->getCurrentWorkingConfig()->commitChanges($previousValue, $value, $path, $valueHasChanged, $message, - true); - return true; } @@ -543,7 +573,9 @@ private function _setInternal( * * --- * ```php - * Craft::$app->projectConfig->remove('foo.bar'); + * use CraftCms\Cms\Support\Facades\ProjectConfig; + * + * ProjectConfig::remove('foo.bar'); * ``` * * @param string $path The config item path @@ -554,1074 +586,214 @@ public function remove(string $path, ?string $message = null): void $this->set($path, null, $message); } - /** - * Regenerates the external config based on the loaded project config. - */ - public function regenerateExternalConfig(): void + /** @param array|null $previousConfig */ + private function commit(string $path, mixed $value, ?string $message = null, bool $force = false, bool $triggerUpdate = false, ?array $previousConfig = null): void { - $this->isApplyingExternalChanges = false; - - // Ensure we have the working config - $this->getCurrentWorkingConfig(); - - // And ensure we save it. - $this->_saveConfigAfterRequest(); - $this->updateParsedConfigTimesAfterRequest(); - $this->saveModifiedConfigData(); - $this->writeYamlFiles(true); - } + $oldValue = $previousConfig === null ? $this->get($path) : ProjectConfigHelper::traverseDataArray($previousConfig, $path); + $changed = $triggerUpdate || ProjectConfigHelper::encodeValueAsString($oldValue) !== ProjectConfigHelper::encodeValueAsString($value) + || ($this->forceUpdate && ($oldValue !== null || $value !== null)); + $this->processedPaths[$path] = true; - /** - * Applies changes in external config to project config. - * - * @throws BusyResourceException if a lock could not be acquired - * @throws StaleResourceException if the loaded project config is out-of-date - */ - public function applyExternalChanges(): void - { - $this->_acquireLock(); + if ($changed && ! $this->muteEvents) { + event($this->changeEvent($path, $oldValue, $value, before: true)); + } - // Disable read/write splitting for the remainder of this request - DB::connection()->useWriteConnectionWhenReading(); + if (($changed || $force) && ! str_starts_with($path, self::PATH_META_NAMES)) { + $this->updateNames(ProjectConfigHelper::lastPathSegment($path), $oldValue, $value); + } - // Start with a clean slate. - $this->reset(); + if ($changed && ! $this->muteEvents) { + event($this->changeEvent($path, $oldValue, $value)); + } - $this->isApplyingExternalChanges = true; - Cache::forget(self::CACHE_KEY); + $parent = $path; - $changes = $this->_getPendingChanges(); + while (($parent = ProjectConfigHelper::pathWithoutLastSegment($parent)) !== null) { + $this->processedPaths[$parent] = true; + } - $this->_applyChanges($changes, $this->getCurrentWorkingConfig(), $this->getExternalConfig()); - $anyChangesApplied = (bool) (count($changes['newItems']) + count($changes['removedItems']) + count($changes['changedItems'])); + if ($changed) { + $this->rememberAppliedChanges($path, $oldValue, $value, $message); + ProjectConfigHelper::traverseDataArray($this->current, $path, $value, $value === null); - // Kill the cached config data - Cache::forget(self::STORED_CACHE_KEY); - if ($anyChangesApplied) { - $this->updateConfigVersion(); + if ($this->writeYamlAutomatically) { + $this->updateParsedConfigTimesAfterRequest(); + } } - - $this->_releaseLock(); } - public function isApplyingExternalChanges(): bool + private function changeEvent(string $path, mixed $oldValue, mixed $newValue, bool $before = false): ConfigEvent { - return $this->isApplyingExternalChanges; + $class = match (true) { + $newValue === null && $oldValue !== null => $before ? ProjectConfigItemRemoved::class : ItemRemoved::class, + $oldValue === null && $newValue !== null => $before ? ProjectConfigItemAdding::class : ItemAdded::class, + default => $before ? ProjectConfigItemUpdated::class : ItemUpdated::class, + }; + + return new $class($path, $oldValue, $newValue); } - /** - * Applies given changes to the project config. - */ - /** @param array $configData */ - public function applyConfigChanges(array $configData): void + private function updateNames(string|int $key, mixed $old, mixed $new): void { - $this->resetClaimedPaths(); - $this->isApplyingExternalChanges = true; + $old = is_array($old) ? $old : []; + $new = is_array($new) ? $new : []; - $changes = $this->_getPendingChanges($configData); - $incomingConfig = new ReadOnlyProjectConfigData($configData, $this); + if (Str::isUuid((string) $key)) { + if (isset($new['name'])) { + $this->setNameMapping($key, $new['name']); + } elseif (isset($old['name'])) { + $this->removeNameMapping($key); + } + } - $this->_applyChanges($changes, $this->getCurrentWorkingConfig(), $incomingConfig); + foreach (array_unique([...array_keys($old), ...array_keys($new)]) as $child) { + $this->updateNames($child, $old[$child] ?? null, $new[$child] ?? null); + } } /** - * Returns whether external project config files appear to exist. + * Sets a UUID/name mapping on the working config. */ - public function getDoesExternalConfigExist(): bool + public function setNameMapping(string $uid, string $name): void { - return file_exists(Path::projectConfigFile()); + $this->setNameMappingInternal($uid, $name); } /** - * Returns whether a given path has pending changes that need to be applied to the loaded project config. - * - * @param string|null $path A specific config path that should be checked for pending changes. - * If this is null, then `true` will be returned if there are *any* pending changes in external config. - * @param bool $force Whether to check for changes even if it doesn’t look like anything has changed since - * the last time [[ignorePendingChanges()]] has been called. + * Removes a UUID/name mapping on the working config. */ - public function areChangesPending(?string $path = null, bool $force = false): bool + public function removeNameMapping(string $uid): void { - // If the path is currently being processed, return true - if ($path !== null && $this->getCurrentWorkingConfig()->getHasPathBeenModified($path)) { - return true; - } + $this->setNameMappingInternal($uid, null); + } - // If the file does not exist, but should, generate it - if ($this->getHadFileWriteIssues() || ! $this->getDoesExternalConfigExist()) { - if ($this->writeYamlAutomatically) { - $this->regenerateExternalConfig(); - } else { - $this->saveModifiedConfigData(); - } + private function setNameMappingInternal(string $uid, ?string $name): void + { + $path = self::PATH_META_NAMES.'.'.$uid; - return false; + if ($this->readOnly || $this->get($path) === $name) { + return; } - if (! $force) { - // If the file modification date hasn't changed, then no need to check the contents - $cachedModifiedTime = Cache::get(self::CACHE_KEY); - if ( - $cachedModifiedTime && - $cachedModifiedTime === $this->_getConfigFileModifiedTime() - ) { - return false; - } - } + $this->acquireLock(); - if ($path !== null) { - $oldValue = $this->getInternalConfig()->get($path); - $newValue = $this->getExternalConfig()->get($path); + try { + $this->commit($path, $name); + } catch (Throwable $exception) { + $this->releaseLock(); - return ProjectConfigHelper::encodeValueAsString($oldValue) !== ProjectConfigHelper::encodeValueAsString($newValue); + throw $exception; } - - // If the file contents haven't changed, just update the cached file modification date - if (! $this->_getPendingChanges(null, true)) { - $this->updateParsedConfigTimes(); - - return false; - } - - // Clear the cached config, just in case it conflicts with what we've got here - Cache::forget(self::STORED_CACHE_KEY); - $this->_currentWorkingConfig = null; - - return true; - } - - /** - * Processes changes in the project config files for a given config item path. - * - * Note that this will only have an effect if external project config changes are currently getting [[getIsApplyingExternalChanges()|applied]]. - * - * @param string $path The config item path - * @param bool $force Whether the config change should be processed regardless of previous records, - * or whether external changes are currently being applied - */ - public function processConfigChanges(string $path, bool $force = false): void - { - if ($force || $this->isApplyingExternalChanges) { - $this->getCurrentWorkingConfig()->commitChanges($this->getInternalConfig()->get($path), - $this->getExternalConfig()->get($path), $path, false, null, $force); - } - } - - /** - * Updates cached config file modified times after the request ends. - */ - public function updateParsedConfigTimesAfterRequest(): void - { - if ($this->waitingToUpdateParsedConfigTimes) { - return; - } - - $this->waitingToUpdateParsedConfigTimes = true; - } - - /** - * Updates cached config file modified times immediately. - */ - public function updateParsedConfigTimes(): bool - { - return Cache::put( - self::CACHE_KEY, - $this->_getConfigFileModifiedTime(), - self::CACHE_DURATION, - ); - } - - /** - * Saves all the config data that has been modified up to now. - */ - public function saveModifiedConfigData(): void - { - $persistedPosition = $this->persistedChanges; - $changes = array_slice($this->_appliedChanges, $persistedPosition); - - if ($changes === []) { - $this->_releaseLock(); - - return; - } - - $deltaChanges = []; - - DB::transaction(function () use ($changes, &$deltaChanges) { - foreach ($changes as $changeSet) { - // Allow modification of the array being looped over. - $currentSet = $changeSet; - - if (! empty($changeSet['removed'])) { - $this->removeInternalConfigValuesByPaths(array_keys($changeSet['removed'])); - } - - if (! empty($changeSet['added'])) { - $isMysql = DB::isMysql(); - $batch = []; - $pathsToInsert = []; - $additionalCleanupPaths = []; - - foreach ($currentSet['added'] as $key => $value) { - // Prepare for storage - $dbValue = ProjectConfigHelper::encodeValueAsString($value); - if (! mb_check_encoding($dbValue, 'UTF-8') || ($isMysql && Str::containsMb4($dbValue))) { - $dbValue = 'base64:'.base64_encode($dbValue); - } - $batch[$key] = $dbValue; - $pathsToInsert[] = $key; - - // Delete parent key, as it cannot hold a value AND be an array at the same time - $additionalCleanupPaths[ProjectConfigHelper::pathWithoutLastSegment($key) ?? $key] = true; - - // Prepare for delta - if (! empty($currentSet['removed']) && array_key_exists($key, $currentSet['removed'])) { - if (is_string($changeSet['removed'][$key])) { - $changeSet['removed'][$key] = Str::decdec($changeSet['removed'][$key]); - } - - $changeSet['removed'][$key] = Json::decodeIfJson($changeSet['removed'][$key]); - - // Ensure types - if (is_bool($value)) { - $changeSet['removed'][$key] = (bool) $changeSet['removed'][$key]; - } elseif (is_int($value)) { - $changeSet['removed'][$key] = (int) $changeSet['removed'][$key]; - } - - if ($changeSet['removed'][$key] === $value) { - unset($changeSet['removed'][$key], $changeSet['added'][$key]); - } elseif (array_key_exists($key, $changeSet['removed'])) { - $changeSet['changed'][$key] = [ - 'from' => $changeSet['removed'][$key], - 'to' => $changeSet['added'][$key], - ]; - - unset($changeSet['removed'][$key], $changeSet['added'][$key]); - } - } - } - - // Store in the DB - if (! empty($batch)) { - $this->removeInternalConfigValuesByPaths($pathsToInsert); - $this->removeInternalConfigValuesByPaths(array_keys($additionalCleanupPaths)); - $this->persistInternalConfigValues($batch); - } - } - - $changeSet = array_filter($changeSet); - - if (! empty($changeSet)) { - $deltaChanges[] = $changeSet; - } - } - - $this->updateConfigVersion(); - $this->_releaseLock(); - }); - - if (! empty($deltaChanges)) { - $this->storeYamlHistory([ - 'dateApplied' => now()->format('Y-m-d H:i:s'), - 'changes' => $deltaChanges, - ]); - } - - DB::afterRollBack(function () use ($persistedPosition): void { - $this->persistedChanges = min($this->persistedChanges, $persistedPosition); - }); - - $this->persistedChanges = $persistedPosition + count($changes); - } - - /** - * Remove values from internal config by a list of paths. - */ - /** @param string[] $paths */ - private function removeInternalConfigValuesByPaths(array $paths): void - { - $chunks = array_chunk($paths, 1000); - - foreach ($chunks as $chunk) { - DB::table(Table::PROJECTCONFIG) - ->whereIn('path', $chunk) - ->delete(); - } - } - - /** - * Persist an array of `$path => $value` to the internal config. - */ - /** @param array $values */ - private function persistInternalConfigValues(array $values): void - { - DB::table(Table::PROJECTCONFIG) - ->insert(Collection::make($values)->map(fn ($value, $path) => [ - 'path' => $path, - 'value' => $value, - ])->all()); - } - - /** - * Get the list of applied changes - */ - /** @return array> */ - public function getAppliedChanges(): array - { - return $this->_appliedChanges; - } - - /** - * Returns whether all schema versions stored in the config are compatible with the actual codebase. - * The schemas must match exactly to avoid unpredictable behavior that can occur when running migrations - * and applying project config changes at the same time. - * - * @param array $issues Passed by reference and populated with issues on error in - * the following format: `[$pluginName, $existingSchema, $incomingSchema]` - */ - public function getAreConfigSchemaVersionsCompatible(array &$issues = []): bool - { - $incomingSchema = (string) $this->getExternalConfig()->get(self::PATH_SCHEMA_VERSION); - $existingSchema = Cms::SCHEMA_VERSION; - - // Compare existing Craft schema version with the one that is being applied. - if (! version_compare($existingSchema, $incomingSchema, '=')) { - $issues[] = [ - 'cause' => 'Craft CMS', - 'existing' => $existingSchema, - 'incoming' => $incomingSchema, - ]; - } - - $plugins = app(Plugins::class)->getAllPlugins(); - - foreach ($plugins as $plugin) { - $incomingSchema = (string) $this->getExternalConfig()->get(self::PATH_PLUGINS.'.'.$plugin->handle.'.schemaVersion'); - $existingSchema = $plugin->schemaVersion; - - // Compare existing plugin schema version with the one that is being applied. - if ($incomingSchema && ! version_compare($existingSchema, $incomingSchema, '=')) { - $issues[] = [ - 'cause' => $plugin->name, - 'existing' => $existingSchema, - 'incoming' => $incomingSchema, - ]; - } - } - - return empty($issues); - } - - // Config Change Event Registration - // ------------------------------------------------------------------------- - - /** - * Attaches an event handler for when an item is added to the config at a given path. - * - * --- - * - * ```php - * use craft\events\ConfigEvent; - * use craft\helpers\Db; - * - * Craft::$app->projectConfig->onAdd('foo.{uid}', function(ConfigEvent $event) { - * // Get the UID from the item path - * $uid = $event->tokenMatches[0]; - * - * // Prep the row data - * $data = array_merge($event->newValue); - * - * // See if the row already exists (maybe it was soft-deleted) - * $id = \Illuminate\Support\Facades\DB::table(\CraftCms\Cms\Db\'{{%tablename}}')->idByUid($uid); - * - * if ($id) { - * $data['dateDeleted'] = null; - * Craft::$app->db->createCommand()->update('{{%tablename}}', $data, [ - * 'id' => $id, - * ]); - * } else { - * $data['uid'] = $uid; - * Craft::$app->db->createCommand()->insert('{{%tablename}}', $data); - * } - * }); - * ``` - * - * @param string $path The config path pattern. Can contain `{uri}` tokens, which will be passed to the handler. - * @param callable $handler The handler method. - * @param mixed $data The data to be passed to the event handler when the event is triggered. - * When the event handler is invoked, this data can be accessed via [[ConfigEvent::data]]. - * @return static self reference - */ - public function onAdd(string $path, callable $handler, mixed $data = null): self - { - $this->registerChangeEventHandler(ItemAdded::class, $path, $handler, $data); - - return $this; - } - - /** - * Attaches an event handler for when an item is updated in the config at a given path. - * - * --- - * - * ```php - * use craft\events\ConfigEvent; - * - * Craft::$app->projectConfig->onUpdate('foo.{uid}', function(ConfigEvent $event) { - * // Get the UID from the item path - * $uid = $event->tokenMatches[0]; - * - * // Update the item in the database - * $data = array_merge($event->newValue); - * Craft::$app->db->createCommand()->update('{{%tablename}}', $data, [ - * 'uid' => $uid, - * ]); - * }); - * ``` - * - * @param string $path The config path pattern. Can contain `{uri}` tokens, which will be passed to the handler. - * @param callable $handler The handler method. - * @param mixed $data The data to be passed to the event handler when the event is triggered. - * When the event handler is invoked, this data can be accessed via [[ConfigEvent::data]]. - * @return static self reference - */ - public function onUpdate(string $path, callable $handler, mixed $data = null): self - { - $this->registerChangeEventHandler(ItemUpdated::class, $path, $handler, $data); - - return $this; - } - - /** - * Attaches an event handler for when an item is removed from the config at a given path. - * - * --- - * - * ```php - * use craft\events\ConfigEvent; - * - * Craft::$app->projectConfig->onRemove('foo.{uid}', function(ConfigEvent $event) { - * // Get the UID from the item path - * $uid = $event->tokenMatches[0]; - * - * // Soft-delete the item from the database - * Craft::$app->db->createCommand()->softDelete('{{%tablename}}', [ - * 'uid' => $uid, - * ]); - * }); - * ``` - * - * @param string $path The config path pattern. Can contain `{uri}` tokens, which will be passed to the handler. - * @param callable $handler The handler method. - * @param mixed $data The data to be passed to the event handler when the event is triggered. - * When the event handler is invoked, this data can be accessed via [[ConfigEvent::data]]. - * @return static self reference - */ - public function onRemove(string $path, callable $handler, mixed $data = null): self - { - $this->registerChangeEventHandler(ItemRemoved::class, $path, $handler, $data); - - return $this; - } - - /** - * Defers an event until all other project config changes have been processed. - */ - public function defer(ConfigEvent $event, callable $handler): void - { - Log::info('Deferring event handler for '.$event->path, [__METHOD__]); - - $this->_deferredEvents[] = [$event, $event->tokenMatches, $handler]; - } - - /** - * Registers a config change event listener, for a specific config path pattern. - * - * @param string $event The event name - * @param string $path The config path pattern. Can contain `{uid}` tokens, which will be passed to the handler. - * @param callable $handler The handler method. - * @param mixed $data The data to be passed to the event handler when the event is triggered. - * When the event handler is invoked, this data can be accessed via [[ConfigEvent::data]]. - */ - public function registerChangeEventHandler(string $event, string $path, callable $handler, mixed $data = null): void - { - $pattern = '/^(?P'.preg_quote($path, '/').')(?P\..+)?$/'; - $pattern = str_replace('\\{uid\\}', '('.self::UID_PATTERN.')', $pattern); - - $this->_changeEventHandlers[$event] ??= []; - $this->_changeEventHandlers[$event][] = [ - 'pattern' => $pattern, - 'handler' => $handler, - 'data' => $data, - 'specificity' => ProjectConfigHelper::pathDepth($path), - 'registrationOrder' => count($this->_changeEventHandlers[$event]), - ]; - unset($this->_sortedChangeEventHandlers[$event]); - } - - /** - * Handles a config change event. - */ - public function handleChangeEvent(ConfigEvent $event): void - { - if (empty($this->_changeEventHandlers[$event::class])) { - return; - } - - // Make sure the event handlers are sorted from least-to-most specific - $this->_sortChangeEventHandlers($event::class); - - foreach ($this->_changeEventHandlers[$event::class] as [ - 'pattern' => $pattern, - 'handler' => $handler, - 'data' => $data, - ]) { - if (! preg_match($pattern, $event->path, $matches)) { - continue; - } - - // Is this a nested path? - if (isset($matches['extra'])) { - $path = $matches['path']; - $incomingConfig = $this->isApplyingExternalChanges ? $this->getExternalConfig() : $this->getCurrentWorkingConfig(); - - $oldValue = $this->getInternalConfig()->get($path); - - // For containing paths we need to do the following things: - // 1) get the previous value at the containing path, which will be stale - // 2) get the extra path component from matches array - // 3) grab the actual new data from the event and merge it over the stale data - $newValue = $incomingConfig->get($path); - $extraPath = Str::chopStart($matches['extra'], '.'); - $newNestedValue = $event->newValue; - if (is_array($newValue)) { - ProjectConfigHelper::traverseDataArray($newValue, $extraPath, $newNestedValue); - } - - $this->getCurrentWorkingConfig()->commitChanges($oldValue, $newValue, $path); - - continue; - } - - // Chop off [0] (full match) and ['path'] & [1] (requested path) - $event->tokenMatches = array_values(array_slice($matches, 3)); - - // Set the event data - $event->data = $data; - - $handler($event); - - $event->tokenMatches = null; - $event->data = null; - } - } - - /** - * Ensures that the config change event handlers are sorted by least-to-most specific. - */ - private function _sortChangeEventHandlers(string $event): void - { - if (isset($this->_sortedChangeEventHandlers[$event])) { - return; - } - - usort( - $this->_changeEventHandlers[$event], - fn (array $a, array $b): int => [$a['specificity'], $a['registrationOrder']] <=> [$b['specificity'], $b['registrationOrder']], - ); - - $this->_sortedChangeEventHandlers[$event] = true; - } - - /** - * Rebuilds the project config from the current state in the database. - * - * @throws BusyResourceException if a lock could not be acquired - * @throws StaleResourceException if the loaded project config is out-of-date - * @throws Throwable if reasons - */ - public function rebuild(): void - { - $this->_acquireLock(); - $this->reset(); - - $this->muteEvents = true; - $readOnly = $this->readOnly; - $this->readOnly = false; - - $config = $this->getInternalConfig()->export(); - - // don't touch `meta` - unset($config[self::PATH_META]); - - $config[self::PATH_ADDRESSES] = $this->_getAddressesData(); - $config[self::PATH_ASSET_TRANSFORMERS] = app(AssetTransformers::class) - ->getAllAssetTransformers() - ->mapWithKeys(fn (AssetTransformer $transformer): array => [ - $transformer->uid => $transformer->getConfig(), - ]) - ->all(); - $config[self::PATH_DATE_MODIFIED] = now()->getTimestamp(); - $config[self::PATH_ELEMENT_SOURCES] = $this->_getElementSourceData($config[self::PATH_ELEMENT_SOURCES] ?? []); - $config[self::PATH_ENTRY_TYPES] = $this->_getEntryTypeData(); - $config[self::PATH_FIELDS] = $this->_getFieldData(); - $config[self::PATH_FS] = $this->_getFsData(); - $config[self::PATH_GRAPHQL] = $this->_getGqlData(); - $config[self::PATH_IMAGE_TRANSFORMS] = $this->_getTransformData(); - $config[self::PATH_PLUGINS] = $this->_getPluginData($config[self::PATH_PLUGINS] ?? []); - $config[self::PATH_SECTIONS] = $this->_getSectionData(); - $config[self::PATH_SITES] = $this->_getSiteData(); - $config[self::PATH_SITE_GROUPS] = $this->_getSiteGroupData(); - $config[self::PATH_SYSTEM] = $this->_systemConfig($config[self::PATH_SYSTEM] ?? []); - $config[self::PATH_USERS] = $this->_getUserData($config[self::PATH_USERS] ?? []); - $config[self::PATH_VOLUMES] = $this->_getVolumeData(); - - // Fire a 'rebuild' event - event($event = new ProjectConfigRebuilt($config)); - - // Reset the component name map - $this->_setInternal(self::PATH_META_NAMES, [], updateTimestamp: false, force: true); - - // Process the changes - foreach ($event->config as $path => $value) { - $this->_setInternal($path, $value, 'Project config rebuild', updateTimestamp: false, force: true); - } - - // Make sure we save it all. - $this->_saveConfigAfterRequest(); - $this->updateConfigVersion(); - - if ($this->writeYamlAutomatically) { - $this->writeYamlFiles(); - } - - // And now ensure that Project Config doesn't attempt to export the config again - $this->_updateYaml = false; - - $this->readOnly = $readOnly; - $this->muteEvents = false; - } - - /** - * Applies changes from a configuration array. - * - * @param array{removedItems: string[], changedItems: string[], newItems: string[]} $changes nested array with keys `removedItems`, `changedItems` and `newItems` - * @param ReadOnlyProjectConfigData $existingConfig The config data repository that holds the current data - * @param ReadOnlyProjectConfigData $incomingConfig The config data repository that holds the incoming data - * - * @throws OperationAbortedException - */ - private function _applyChanges( - array $changes, - ReadOnlyProjectConfigData $existingConfig, - ReadOnlyProjectConfigData $incomingConfig, - ): void { - Log::info('Looking for pending changes', [__METHOD__]); - - $processChanges = function ($path, $triggerUpdate = false) use ($existingConfig, $incomingConfig) { - $oldValue = $existingConfig->get($path); - $newValue = $incomingConfig->get($path); - $this->getCurrentWorkingConfig()->commitChanges($oldValue, $newValue, $path, $triggerUpdate, null, true); - }; - - // If we're parsing all the changes, we better work the actual config map. - if (! empty($changes['removedItems'])) { - Log::info('Parsing '.count($changes['removedItems']).' removed configuration items', [__METHOD__]); - foreach ($changes['removedItems'] as $itemPath) { - $processChanges($itemPath); - } - } - - if (! empty($changes['changedItems'])) { - Log::info('Parsing '.count($changes['changedItems']).' changed configuration items', [__METHOD__]); - foreach ($changes['changedItems'] as $itemPath) { - $processChanges($itemPath); - } - } - - if (! empty($changes['newItems'])) { - Log::info('Parsing '.count($changes['newItems']).' new configuration items', [__METHOD__]); - // It's possible that a key has both a new value and a changed value. - // Make sure we process paths that might have been added but not processed yet. - foreach ($changes['newItems'] as $itemPath) { - $processChanges($itemPath, true); - } - } - - $defers = -count($this->_deferredEvents); - while (! empty($this->_deferredEvents)) { - if ($defers > $this->maxDefers) { - $paths = []; - - // Grab a list of all deferred event paths - foreach ($this->_deferredEvents as [$deferredEvent]) { - // Save us the trouble of filtering out duplicates later - $paths[$deferredEvent->path] = true; - } - - $message = "The following config paths could not be processed successfully:\n".implode("\n", - array_keys($paths)); - throw new OperationAbortedException($message); - } - - /** @var ConfigEvent $event */ - /** @var string[]|null $tokenMatches */ - /** @var callable $handler */ - [$event, $tokenMatches, $handler] = array_shift($this->_deferredEvents); - Log::info('Re-triggering deferred event for '.$event->path, [__METHOD__]); - $event->tokenMatches = $tokenMatches; - $handler($event); - $event->tokenMatches = null; - $defers++; - } - - Log::info('Finalizing configuration parsing', [__METHOD__]); - - event(new ChangesApplied); - - $this->updateParsedConfigTimesAfterRequest(); - $this->isApplyingExternalChanges = false; - } - - /** - * Retrieve a config file tree with modified times based on the main configuration file. - */ - private function _getConfigFileModifiedTime(): int - { - $path = Path::projectConfigFile(); - - if (! file_exists($path)) { - return 0; - } - - return filemtime($path); - } - - /** - * Load the config stored in the external storage. - */ - private function _loadExternalConfig(): ReadOnlyProjectConfigData - { - // If the external config does not exist, just use the loaded config - if ($this->getHadFileWriteIssues() || ! $this->getDoesExternalConfigExist()) { - return $this->getCurrentWorkingConfig(); - } - - $fileList = $this->_getConfigFileList(); - $generatedConfig = []; - $projectConfigPathLength = strlen((string) Path::projectConfig(create: false)); - - foreach ($fileList as $filePath) { - $yamlConfig = Yaml::parse(file_get_contents($filePath)); - $subPath = substr((string) $filePath, $projectConfigPathLength + 1); - - if (Str::substrCount($subPath, DIRECTORY_SEPARATOR) > 0) { - $configPath = explode(DIRECTORY_SEPARATOR, $subPath); - $filename = pathinfo(array_pop($configPath), PATHINFO_FILENAME); - $insertionPoint = &$generatedConfig; - - foreach ($configPath as $pathSegment) { - $insertionPoint[$pathSegment] ??= []; - - $insertionPoint = &$insertionPoint[$pathSegment]; - } - - /** @var string $pathSegment */ - /** @phpstan-ignore-next-line */ - if ($pathSegment === $filename) { - $insertionPoint = array_merge($insertionPoint, $yamlConfig); - } else { - // Is this in the -- format? - if (preg_match('/^\w+--('.Str::uuidPattern().')$/', $filename, $match)) { - // Ignore the handle - $filename = $match[1]; - } - $insertionPoint[$filename] = $yamlConfig; - } - } else { - $generatedConfig = array_merge($generatedConfig, $yamlConfig); - } - } - - return new ReadOnlyProjectConfigData($generatedConfig, $this); - } - - /** - * Return a nested array for pending config changes - * - * @param array|null $configData config data to use. If null, the config is fetched from the project config files. - * @param bool $existsOnly whether to just return `true` or `false` depending on whether any changes are found. - * @return bool|array{newItems: string[], removedItems: string[], changedItems: string[]} - */ - protected function _getPendingChanges(?array $configData = null, bool $existsOnly = false): bool|array - { - $newItems = []; - $changedItems = []; - - $currentConfig = $this->getCurrentWorkingConfig()->export(); - - $configData ??= $this->getExternalConfig()->export(); - - unset($configData['imports'], $currentConfig['imports']); - - // flatten both configs so we can compare them. - $flatConfig = []; - $flatCurrent = []; - - ProjectConfigHelper::flattenConfigArray($configData, '', $flatConfig); - ProjectConfigHelper::flattenConfigArray($currentConfig, '', $flatCurrent); - - // Compare and if something is different, mark the immediate parent as changed. - foreach ($flatConfig as $key => $value) { - // Drop the last part of path - $immediateParent = ProjectConfigHelper::pathWithoutLastSegment($key) ?? $key; - - if (! array_key_exists($key, $flatCurrent)) { - if ($existsOnly) { - return true; - } - $newItems[] = $immediateParent; - } elseif ($this->forceUpdate || $flatCurrent[$key] !== $value) { - if ($existsOnly) { - return true; - } - $changedItems[] = $immediateParent; - } - - unset($flatCurrent[$key]); - } - - if ($existsOnly) { - return ! empty($flatCurrent); - } - - $removedItems = array_keys($flatCurrent); - - foreach ($removedItems as &$removedItem) { - // Drop the last part of path - $removedItem = ProjectConfigHelper::pathWithoutLastSegment($removedItem) ?? $removedItem; - } - - unset($removedItem); - - // Group paths by similarity, sorted by depth (descending), e.g.: - // - foo1.bar.baz - // - foo1.bar - // - foo2.bar.baz - // - foo2.bar - $sorter = function ($a, $b) { - if (str_starts_with($a, "$b.")) { - // a is a subpath of b - return -1; - } - if (str_starts_with($b, "$a.")) { - // b is a subpath of a - return 1; - } - - // find the first segment where they differ and sort based on that - $aSegs = ProjectConfigHelper::pathSegments($a); - $bSegs = ProjectConfigHelper::pathSegments($b); - - foreach ($aSegs as $i => $aSeg) { - $result = $aSeg <=> $bSegs[$i]; - if ($result !== 0) { - return $result; - } - } - - return 0; - }; - - $newItems = array_unique($newItems); - $removedItems = array_unique($removedItems); - $changedItems = array_unique($changedItems); - - uasort($newItems, $sorter); - uasort($removedItems, $sorter); - uasort($changedItems, $sorter); - - return compact('newItems', 'removedItems', 'changedItems'); - } - - /** - * Figure out the entire list of yaml config files - */ - /** @return string[] */ - private function _getConfigFileList(): array - { - if (! empty($this->_configFileList)) { - return $this->_configFileList; - } - - return $this->_configFileList = $this->_findConfigFiles(); } /** - * Finds all of the `.yaml` files in the `config/project/` folder. - * + * Records the added and removed values for a path in the change history. * - * @return string[] + * @param string|null $message message describing the changes made. */ - private function _findConfigFiles(?string $path = null): array + public function rememberAppliedChanges(string $path, mixed $oldValue, mixed $newValue, ?string $message = null): void { - $path ??= Path::projectConfig(create: false); - if (! is_dir($path)) { - return []; - } - - $finder = Finder::create() - ->ignoreDotFiles(false) - ->ignoreVCS(false) - ->files() - ->in($path) - ->filter(fn (SplFileInfo $file): bool => fnmatch('*.yaml', $file->getFilename(), FNM_CASEFOLD)); - - $list = []; - foreach ($finder as $file) { - $list[] = $file->getPathname(); - } - - return $list; + $this->changes[] = ConfigChanges::history($path, $oldValue, $newValue, $message); } - /** - * Save configuration data after the request. - */ - private function _saveConfigAfterRequest(): void + /** @return list, removed?: array, message?: string}> */ + public function getAppliedChanges(): array { - $this->_updateYaml = true; + return $this->changes; } /** - * Store yaml history - * - * @param array $configData config data to be saved as history - * - * @throws Exception + * Saves all the config data that has been modified up to now. */ - private function storeYamlHistory(array $configData): void + public function saveModifiedConfigData(): void { - $basePath = Path::configDelta(self::CONFIG_DELTA_FILENAME); - - // Go through all of them and move them forward. - for ($i = $this->maxDeltas; $i > 0; $i--) { - $thisFile = $basePath.($i == 1 ? '' : '.'.($i - 1)); - if (file_exists($thisFile)) { - if ($i === $this->maxDeltas) { - @unlink($thisFile); - } else { - @rename($thisFile, "$basePath.$i"); + try { + $end = count($this->changes); + + if ($this->persistedChanges < $end) { + $start = $this->persistedChanges; + $this->storage->save(array_slice($this->changes, $start)); + $this->persistedChanges = $end; + + if (DB::transactionLevel() > 0) { + $generation = $this->generation; + DB::afterRollBack(function () use ($start, $generation): void { + if ($this->generation === $generation) { + $this->persistedChanges = min($start, $this->persistedChanges); + Cache::forget(self::STORED_CACHE_KEY); + } + }); } } - } - File::writeToFile($basePath, Yaml::dump($configData, 20, 2, Yaml::DUMP_COMPACT_NESTED_MAPPING)); + if ($this->loggedChanges < $end) { + $this->storage->writeDelta(array_slice($this->changes, $this->loggedChanges), $this->maxDeltas); + $this->loggedChanges = $end; + } + } finally { + $this->releaseLock(); + } } /** - * Updates the config version used for cache invalidation. + * Saves the modified project config state and writes out updated YAML files, if needed. */ - private function updateConfigVersion(): void + public function flush(): void { - Info::fetch()->update([ - 'configVersion' => Str::random(12), - ]); + $this->saveModifiedConfigData(); + + if ($this->writeYamlAutomatically) { + $this->writeYamlFiles(); + } } - /** - * Update the config YAML files with the buffered changes. - * - * @param bool $force Whether to write out the YAML even if there aren’t any new changes - * - * @throws Exception if something goes wrong - */ - public function writeYamlFiles(bool $force = false): void + private function acquireLock(): void { - if (! $this->_updateYaml && ! $force) { + if ($this->lock !== null) { return; } - $config = $this->getCurrentWorkingConfig(); - - try { - $basePath = Path::projectConfig(); - - // Delete everything except hidden files/folders - File::cleanDirectory($basePath, except: ['.*', '.*/']); + $lock = Cache::lock(self::MUTEX_NAME, 30); - $projectConfigNames = $config->get(self::PATH_META_NAMES); - - $uids = []; - $replacements = []; - - if (! empty($projectConfigNames)) { - foreach ($projectConfigNames as $uid => $name) { - $name = trim((string) $name); - - if ($name !== '') { - $uids[] = sprintf('/^.*\b%s\b.*$/m', preg_quote((string) $uid)); - $replacements[] = "$0 # $name"; - } - } - } + if (! $lock->get()) { + throw new BusyResourceException('Project config is being modified by another process.'); + } - $splitConfig = ProjectConfigHelper::splitConfigIntoComponents($config->export()); - foreach ($splitConfig as $relativeFile => $configData) { - $configData = ProjectConfigHelper::cleanupConfig($configData); - ksort($configData); - $filePath = join_paths($basePath, $relativeFile); - $yamlContent = Yaml::dump($configData, 20, 2, Yaml::DUMP_COMPACT_NESTED_MAPPING); - if (! empty($uids)) { - $yamlContent = preg_replace($uids, $replacements, $yamlContent); - } - File::writeToFile($filePath, $yamlContent); - } - } catch (Throwable $e) { - Cache::put(self::FILE_ISSUES_CACHE_KEY, true, self::CACHE_DURATION); - if (isset($basePath)) { - // Try to delete everything (again?) so Craft doesn't apply half-baked project config data - try { - File::cleanDirectory($basePath, except: ['.*', '.*/']); - } catch (Throwable) { - // oh well - } - } + $this->lock = $lock; - throw new Exception('Unable to write new project config files', 0, $e); + try { + $version = DB::table(Table::INFO)->value('configVersion'); + } catch (PDOException) { + return; } - Cache::forget(self::FILE_ISSUES_CACHE_KEY); + if ($version !== null && $version !== Info::fetch()->configVersion) { + $this->releaseLock(); - // Let plugins know about it - event(new YamlFilesWritten); - - $this->_updateYaml = false; + throw new StaleResourceException('Project config has changed since this request started.'); + } } - /** - * Sets a UUID/name mapping on the working config. - */ - public function setNameMapping(string $uid, string $name): void + private function releaseLock(): void { - $this->setNameMappingInternal($uid, $name); + $this->lock?->release(); + $this->lock = null; } /** - * Removes a UUID/name mapping on the working config. + * Returns the cache dependency that should be used for project config caches. */ - public function removeNameMapping(string $uid): void + public function getCacheDependency(): CallbackDependency { - $this->setNameMappingInternal($uid, null); + return $this->storage->dependency(); } - private function setNameMappingInternal(string $uid, ?string $name): void + /** + * Returns whether external project config files appear to exist. + */ + public function getDoesExternalConfigExist(): bool { - if (! $this->readOnly) { - // call _setInternal() so we avoid recursive calls to _saveConfigAfterRequest() via set() - $this->_setInternal(sprintf('%s.%s', self::PATH_META_NAMES, $uid), $name, updateTimestamp: false); - } + return $this->storage->exists($this->folderName); } /** @@ -1629,417 +801,246 @@ private function setNameMappingInternal(string $uid, ?string $name): void */ public function getHadFileWriteIssues(): bool { - return $this->writeYamlAutomatically && Cache::get(self::FILE_ISSUES_CACHE_KEY); + return $this->writeYamlAutomatically && (bool) Cache::get(self::FILE_ISSUES_CACHE_KEY); } /** - * Update Craft's internal config store for a path with the new value. If the value - * is null, it will be removed instead. + * Update the config YAML files with the buffered changes. * - * @param string|null $message message describing the changes made. + * @param bool $force Whether to write out the YAML even if there aren’t any new changes + * + * @throws \Exception if something goes wrong */ - public function rememberAppliedChanges( - string $path, - mixed $oldValue, - mixed $newValue, - ?string $message = null, - ): void { - $appliedChanges = []; - - $modified = ProjectConfigHelper::encodeValueAsString($oldValue) !== ProjectConfigHelper::encodeValueAsString($newValue); - - if ($newValue !== null && ($oldValue === null || $modified)) { - if (! is_scalar($newValue)) { - $flatData = []; - ProjectConfigHelper::flattenConfigArray($newValue, $path, $flatData); - } else { - $flatData = [$path => $newValue]; - } - - $appliedChanges['added'] = $flatData; - } - - if ($oldValue && ($newValue === null || $modified)) { - if (! is_scalar($oldValue)) { - $flatData = []; - ProjectConfigHelper::flattenConfigArray($oldValue, $path, $flatData); - } else { - $flatData = [$path => $oldValue]; - } - - $appliedChanges['removed'] = $flatData; - } - - if ($message) { - $appliedChanges['message'] = $message; + public function writeYamlFiles(bool $force = false): void + { + if (! $force && ! $this->yamlDirty) { + return; } - $this->_appliedChanges[] = $appliedChanges; + $this->storage->writeYaml($this->folderName, $this->get()); + $this->yamlDirty = false; } /** - * Get the external project config data. + * Regenerates the external config based on the loaded project config. */ - private function getExternalConfig(): ReadOnlyProjectConfigData + public function regenerateExternalConfig(): void { - $this->_externalConfig ??= $this->_loadExternalConfig(); - - return $this->_externalConfig; + $this->isApplyingExternalChanges = false; + $this->saveModifiedConfigData(); + $this->updateParsedConfigTimesAfterRequest(); + $this->writeYamlFiles(true); } /** - * Get the internal project config data. + * Updates cached config file modified times after the request ends. */ - private function getInternalConfig(): ReadOnlyProjectConfigData + public function updateParsedConfigTimesAfterRequest(): void { - $this->_internalConfig ??= $this->_loadInternalConfig(); - - return $this->_internalConfig; + $this->waitingToUpdateParsedConfigTimes = true; } /** - * Get the current working project config data. + * Updates cached config file modified times immediately. */ - private function getCurrentWorkingConfig(): ProjectConfigData + public function updateParsedConfigTimes(): bool { - return $this->_currentWorkingConfig ??= new ProjectConfigData( - data: $this->getInternalConfig()->export(), - projectConfig: $this, - ); + return $this->storage->updateParsedTime($this->folderName); } /** - * Load the config stored in the Db + * Returns whether a given path has pending changes that need to be applied to the loaded project config. + * + * @param string|null $path A specific config path that should be checked for pending changes. + * If this is null, then `true` will be returned if there are *any* pending changes in external config. + * @param bool $force Whether to check for changes even if it doesn’t look like anything has changed since + * the last time the cached file modification time was updated. */ - private function _loadInternalConfig(): ReadOnlyProjectConfigData + public function areChangesPending(?string $path = null, bool $force = false): bool { - if (! Cms::isInstalled()) { - return new ReadOnlyProjectConfigData([], $this); - } - - if (version_compare(Info::fetch()->schemaVersion, '3.1.1', '<')) { - return new ReadOnlyProjectConfigData([], $this); + if ($path !== null && isset($this->processedPaths[$path])) { + return true; } - if (version_compare(Info::fetch()->schemaVersion, '3.4.4', '<')) { - /** @phpstan-ignore-next-line */ - $config = Info::fetch()->config; - - $data = []; + if (! $this->getDoesExternalConfigExist() || $this->getHadFileWriteIssues()) { + $this->writeYamlAutomatically ? $this->regenerateExternalConfig() : $this->saveModifiedConfigData(); - if ($config) { - // Try to decode it in case it contains any 4+ byte characters - $config = Str::decdec($config); - if (str_starts_with($config, '{')) { - $data = Json::decode($config); - } else { - $data = unserialize($config, ['allowed_classes' => false]); - } - } + return false; + } - return new ReadOnlyProjectConfigData($data, $this); + if (! $force && $this->storage->parsedTimeMatches($this->folderName)) { + return false; } - // See if we can get away with using the cached data - $data = DependencyCache::remember(self::STORED_CACHE_KEY, $this->cacheDuration, function () { - $data = []; - // Load the project config data - // Paths only need parent-before-child ordering, not locale-aware sorting. - $orderBy = DB::connection()->isPgsql() ? new Expression('path COLLATE "C"') : 'path'; - $rows = DB::table(Table::PROJECTCONFIG)->orderBy($orderBy)->pluck('value', 'path'); - - foreach ($rows as $path => $value) { - $current = &$data; - $segments = ProjectConfigHelper::pathSegments($path); - foreach ($segments as $segment) { - // If we're still traversing, enforce array to avoid errors. - if (! is_array($current)) { - $current = []; - } - if (! array_key_exists($segment, $current)) { - $current[$segment] = []; - } - $current = &$current[$segment]; - } - $current = Json::decode(Str::decdec($value)); - } + if ($path !== null) { + $this->get(); - return ProjectConfigHelper::cleanupConfig($data); - }, $this->getCacheDependency()); + return ProjectConfigHelper::encodeValueAsString(ProjectConfigHelper::traverseDataArray($this->original, $path)) + !== ProjectConfigHelper::encodeValueAsString($this->get($path, true)); + } - return new ReadOnlyProjectConfigData($data, $this); - } + if (array_filter($this->getPendingChanges())) { + Cache::forget(self::STORED_CACHE_KEY); - /** - * Returns the cache dependency that should be used for project config caches. - */ - public function getCacheDependency(): CallbackDependency - { - return new CallbackDependency(fn () => Info::fetch()->configVersion); - } + return true; + } - /** - * Returns the system config array. - */ - /** - * @param array $data - * @return array - */ - private function _systemConfig(array $data): array - { - $data['schemaVersion'] = Info::fetch()->schemaVersion; + $this->updateParsedConfigTimes(); - return $data; + return false; } /** - * Return site data config array. + * @param array|null $configData + * @return array{newItems: list, removedItems: list, changedItems: list} */ - /** @return array> */ - private function _getSiteGroupData(): array + public function getPendingChanges(?array $configData = null): array { - return SiteGroups::getAllGroups() - ->mapWithKeys(fn (SiteGroup $group) => [$group->uid => $group->getConfig()]) - ->all(); + return ConfigChanges::pending($this->get(), $configData ?? $this->get(null, true), $this->forceUpdate); } /** - * Return site data config array. + * Returns whether external project config changes are currently being applied. */ - /** @return array> */ - private function _getSiteData(): array + public function isApplyingExternalChanges(): bool { - return Sites::getAllSites(true) - ->mapWithKeys(fn (Site $site) => [$site->uid => $site->getConfig()]) - ->all(); + return $this->isApplyingExternalChanges; } /** - * Return section data config array. + * Applies changes in external config to project config. + * + * @throws BusyResourceException if a lock could not be acquired + * @throws StaleResourceException if the loaded project config is out-of-date */ - /** @return array> */ - private function _getSectionData(): array + public function applyExternalChanges(): void { - return Sections::getAllSections() - ->mapWithKeys(fn (Section $section) => [$section->uid => $section->getConfig()]) - ->all(); - } + $this->acquireLock(); - /** - * Returns element source data. - */ - /** - * @param array>> $sourceConfigs - * @return array>> - */ - private function _getElementSourceData(array $sourceConfigs): array - { - foreach ($sourceConfigs as &$elementTypeConfigs) { - foreach ($elementTypeConfigs as &$config) { - if ($config['type'] === ElementSources::TYPE_CUSTOM && isset($config['condition'])) { - try { - $config['condition'] = Conditions::createCondition($config['condition'])->getConfig(); - } catch (InvalidArgumentException|RuntimeException) { - // Ignore it - } - } + try { + DB::connection()->useWriteConnectionWhenReading(); + $this->reset(); + Cache::forget(self::CACHE_KEY); + $incoming = $this->get(null, true); + $changed = array_filter($this->getPendingChanges($incoming)); + $this->applyConfigChanges($incoming); + + if ($changed) { + $this->storage->invalidate(); } + } finally { + $this->releaseLock(); } - - return $sourceConfigs; - } - - /** - * Return entry type data config array. - */ - /** @return array> */ - private function _getEntryTypeData(): array - { - return EntryTypes::getAllEntryTypes() - ->mapWithKeys(fn (EntryType $entryType) => [$entryType->uid => $entryType->getConfig()]) - ->all(); - } - - /** - * Returns filesystem config data. - */ - /** @return array> */ - private function _getFsData(): array - { - return Filesystems::getAllFilesystems() - ->mapWithKeys(fn (FsInterface $fs) => [$fs->handle => Filesystems::createFilesystemConfig($fs)]) - ->all(); } - /** - * Return field data config array. - */ - /** @return array> */ - private function _getFieldData(): array + /** @param array $configData */ + public function applyConfigChanges(array $configData): void { - return Fields::getAllFields('global') - ->mapWithKeys(fn (FieldInterface $field) => [$field->uid => Fields::createFieldConfig($field)]) - ->all(); - } + $this->resetClaimedPaths(); + $this->processedPaths = []; + $this->handlers->reset(); + $this->isApplyingExternalChanges = true; - /** - * Return volume data config array. - */ - /** @return array> */ - private function _getVolumeData(): array - { - return Volumes::getAllVolumes() - ->mapWithKeys(fn (Volume $volume) => [$volume->uid => $volume->getConfig()]) - ->all(); - } + try { + $changes = $this->getPendingChanges($configData); - /** - * Return user data config array. - */ - /** - * @param array $data - * @return array - */ - private function _getUserData(array $data): array - { - $fieldLayout = Fields::getLayoutByType(User::class, false); - $fieldLayoutConfig = $fieldLayout?->getConfig(); - - if ($fieldLayoutConfig) { - $data['fieldLayouts'] = [ - $fieldLayout->uid => $fieldLayoutConfig, - ]; - } else { - unset($data['fieldLayouts']); - } + foreach (['removedItems', 'changedItems', 'newItems'] as $category) { + foreach ($changes[$category] as $path) { + // A path can contain both changed and new values; new items still need their handlers. + $this->commit($path, ProjectConfigHelper::traverseDataArray($configData, $path), triggerUpdate: $category === 'newItems'); + } + } - $data['groups'] = []; + $this->handlers->runDeferred($this->maxDefers); + event(new ChangesApplied); + $this->updateParsedConfigTimesAfterRequest(); + } catch (Throwable $exception) { + $this->releaseLock(); - foreach (UserGroups::getAllGroups() as $group) { - $data['groups'][$group->uid] = $group->getConfig(); + throw $exception; + } finally { + $this->isApplyingExternalChanges = false; } - - return $data; } /** - * Return addresses data config array. + * Processes changes in the project config files for a given config item path. + * + * Note that this will only have an effect if external project config changes are currently getting [[isApplyingExternalChanges()|applied]]. + * + * @param string $path The config item path + * @param bool $force Whether the config change should be processed regardless of previous records, + * or whether external changes are currently being applied */ - /** @return array */ - private function _getAddressesData(): array + public function processConfigChanges(string $path, bool $force = false): void { - $data = []; - $fieldLayout = Fields::getLayoutByType(Address::class, false); - $fieldLayoutConfig = $fieldLayout?->getConfig(); - - if ($fieldLayoutConfig) { - $data['fieldLayouts'] = [ - $fieldLayout->uid => $fieldLayoutConfig, - ]; + if ((! $this->isApplyingExternalChanges && ! $force) || (! $force && isset($this->processedPaths[$path]))) { + return; } - return $data; + $value = $this->get($path, true); + $this->commit($path, $value, force: $force, previousConfig: $this->original); } /** - * Return plugin data config array - */ - /** - * @param array> $currentPluginData - * @return array> + * Rebuilds the project config from the current state in the database. + * + * @throws BusyResourceException if a lock could not be acquired + * @throws StaleResourceException if the loaded project config is out-of-date + * @throws Throwable if rebuilding or a rebuild listener fails */ - private function _getPluginData(array $currentPluginData): array + public function rebuild(): void { - return DB::table(Table::PLUGINS) - ->select(['handle', 'schemaVersion']) - ->get() - ->mapWithKeys(fn (object $plugin) => [$plugin->handle => array_merge( - $currentPluginData[$plugin->handle] ?? [], - ['schemaVersion' => $plugin->schemaVersion], - )]) - ->all(); - } + $this->acquireLock(); + $readOnly = $this->readOnly; + $muteEvents = $this->muteEvents; - /** - * Return asset transform config array - */ - /** @return array> */ - private function _getTransformData(): array - { - return app(ImageTransforms::class)->getAllTransforms() - ->mapWithKeys(fn (ImageTransform $transform) => [$transform->uid => $transform->getConfig()]) - ->all(); - } + try { + $this->reset(); + $this->readOnly = false; + $this->muteEvents = true; + $rebuilt = new ProjectConfigRebuilt(app(ConfigRebuilder::class)->build($this->get())); + event($rebuilt); + $this->commit(self::PATH_META_NAMES, null); + + foreach ($rebuilt->config as $path => $value) { + $this->set($path, $value, 'Project config rebuild', updateTimestamp: false, force: true); + } - /** - * Return GraphQL config array - */ - /** @return array */ - private function _getGqlData(): array - { - $publicToken = Gql::getPublicToken(); - - $data = [ - 'schemas' => [], - 'publicToken' => [ - 'enabled' => $publicToken->enabled ?? false, - 'expiryDate' => ($publicToken->expiryDate ?? false) ? $publicToken->expiryDate->getTimestamp() : null, - ], - ]; - - foreach (Gql::getSchemas() as $schema) { - $data['schemas'][$schema->uid] = $schema->getConfig(); - } + $this->storage->invalidate(); - return $data; + if ($this->writeYamlAutomatically) { + $this->writeYamlFiles(true); + } + } finally { + $this->readOnly = $readOnly; + $this->muteEvents = $muteEvents; + $this->releaseLock(); + } } /** - * Acquires a mutex lock on the project config, and then ensures that we’ve actually got the latest - * and greatest version of it. + * Returns whether all schema versions stored in the config match the current codebase. + * The schemas must match exactly to avoid applying project config changes while migrations are pending. * - * @throws BusyResourceException if a lock could not be acquired - * @throws StaleResourceException if the loaded project config is out-of-date + * @param list $issues Populated with incompatible schema versions. */ - private function _acquireLock(): void + public function getAreConfigSchemaVersionsCompatible(array &$issues = []): bool { - if ($this->_mutex !== null) { - return; - } + $incoming = $this->get(self::PATH_SCHEMA_VERSION, true); - $mutex = Cache::lock(self::MUTEX_NAME, 30); - - if (! $mutex->get()) { - throw new BusyResourceException('A lock could not be acquired to modify the project config.'); + if (version_compare(Cms::SCHEMA_VERSION, (string) $incoming, '!=')) { + $issues[] = ['cause' => 'Craft CMS', 'existing' => Cms::SCHEMA_VERSION, 'incoming' => $incoming]; } - if (Cms::isInstalled()) { - try { - $storedConfigVersion = DB::table(Table::INFO)->value('configVersion'); - } catch (Throwable) { - $storedConfigVersion = null; - } + foreach (app(Plugins::class)->getAllPlugins() as $plugin) { + $incoming = $this->get(self::PATH_PLUGINS.'.'.$plugin->handle.'.schemaVersion', true); - if ($storedConfigVersion && $storedConfigVersion !== Info::fetch()->configVersion) { - // Another request must have updated the project config after this request began - $mutex->release(); - throw new StaleResourceException('The loaded project config is out-of-date.'); + if ($incoming !== null && version_compare($plugin->schemaVersion, (string) $incoming, '!=')) { + $issues[] = ['cause' => $plugin->name ?? $plugin->handle, 'existing' => $plugin->schemaVersion, 'incoming' => $incoming]; } } - $this->_mutex = $mutex; - } - - /** - * Releases the mutex lock on the project config. - */ - private function _releaseLock(): void - { - if ($this->_mutex === null) { - return; - } - - $this->_mutex->release(); - $this->_mutex = null; + return $issues === []; } } diff --git a/src/Support/Facades/ProjectConfig.php b/src/Support/Facades/ProjectConfig.php index 29de9137633..564d8154198 100644 --- a/src/Support/Facades/ProjectConfig.php +++ b/src/Support/Facades/ProjectConfig.php @@ -24,6 +24,7 @@ * @method static void updateParsedConfigTimesAfterRequest() * @method static bool updateParsedConfigTimes() * @method static void saveModifiedConfigData() + * @method static array getPendingChanges(?array $configData = null) * @method static array getAppliedChanges() * @method static bool getAreConfigSchemaVersionsCompatible(array $issues = []) * @method static \CraftCms\Cms\ProjectConfig\ProjectConfig onAdd(string $path, callable $handler, mixed $data = null) diff --git a/src/Support/File.php b/src/Support/File.php index a9ede9e1cdd..b3d6a32133d 100644 --- a/src/Support/File.php +++ b/src/Support/File.php @@ -213,13 +213,13 @@ public static function cleanDirectory(string $directory, array $except = []): bo continue; } - if ($item->isDir() && ! $item->isLink()) { - app(Filesystem::class)->deleteDirectory($item->getPathname()); + $deleted = $item->isDir() && ! $item->isLink() + ? app(Filesystem::class)->deleteDirectory($item->getPathname()) + : app(Filesystem::class)->delete($item->getPathname()); - continue; + if (! $deleted) { + return false; } - - app(Filesystem::class)->delete($item->getPathname()); } return true; diff --git a/tests/Feature/Console/Commands/Utils/FixFieldLayoutUidsCommandTest.php b/tests/Feature/Console/Commands/Utils/FixFieldLayoutUidsCommandTest.php index 75099ea3012..d50aa7e4bdd 100644 --- a/tests/Feature/Console/Commands/Utils/FixFieldLayoutUidsCommandTest.php +++ b/tests/Feature/Console/Commands/Utils/FixFieldLayoutUidsCommandTest.php @@ -3,7 +3,6 @@ declare(strict_types=1); use CraftCms\Cms\FieldLayout\LayoutElements\CustomField; -use CraftCms\Cms\ProjectConfig\Data\ReadOnlyProjectConfigData; use CraftCms\Cms\ProjectConfig\ProjectConfigHelper; use CraftCms\Cms\Support\Facades\ProjectConfig; use CraftCms\Cms\Support\Str; @@ -88,8 +87,8 @@ if ($external) { $snapshot = ProjectConfig::get(); $snapshot['testFixture']['pending'] = true; - new ReflectionProperty(ProjectConfig::getFacadeRoot(), '_externalConfig') - ->setValue(ProjectConfig::getFacadeRoot(), new ReadOnlyProjectConfigData($snapshot, ProjectConfig::getFacadeRoot())); + new ReflectionProperty(ProjectConfig::getFacadeRoot(), 'external') + ->setValue(ProjectConfig::getFacadeRoot(), $snapshot); expect(ProjectConfig::areChangesPending(force: true))->toBeTrue(); } diff --git a/tests/Feature/ProjectConfig/ProjectConfigHelperTest.php b/tests/Feature/ProjectConfig/ProjectConfigHelperTest.php index f9593187118..41f4a4c6703 100644 --- a/tests/Feature/ProjectConfig/ProjectConfigHelperTest.php +++ b/tests/Feature/ProjectConfig/ProjectConfigHelperTest.php @@ -2,6 +2,7 @@ declare(strict_types=1); +use CraftCms\Cms\Cms; use CraftCms\Cms\ProjectConfig\ProjectConfig; use CraftCms\Cms\ProjectConfig\ProjectConfigHelper; use CraftCms\Cms\Support\DateTimeHelper; @@ -544,7 +545,8 @@ }); test('path claims prevent recursive processing and reset every category', function (string $method, array $paths) { - $projectConfig = $this->partialMock(ProjectConfig::class); + $projectConfig = Mockery::mock(ProjectConfig::class, [Cms::config()])->makePartial(); + $this->instance(ProjectConfig::class, $projectConfig); new ReflectionProperty(ProjectConfig::class, 'isApplyingExternalChanges')->setValue($projectConfig, true); $projectConfig->shouldReceive('get')->andReturn(['item' => []]); $processed = []; @@ -573,7 +575,8 @@ ]); test('forced site processing bypasses only the active application guard', function () { - $projectConfig = $this->partialMock(ProjectConfig::class); + $projectConfig = Mockery::mock(ProjectConfig::class, [Cms::config()])->makePartial(); + $this->instance(ProjectConfig::class, $projectConfig); $projectConfig->shouldReceive('get')->andReturn(['item' => []]); $projectConfig->shouldReceive('processConfigChanges')->with(ProjectConfig::PATH_SITE_GROUPS.'.item', true)->once()->ordered(); $projectConfig->shouldReceive('processConfigChanges')->with(ProjectConfig::PATH_SITES.'.item', true)->once()->ordered(); diff --git a/tests/Feature/ProjectConfig/ProjectConfigTest.php b/tests/Feature/ProjectConfig/ProjectConfigTest.php index 7e9870f333a..c1067c7d573 100644 --- a/tests/Feature/ProjectConfig/ProjectConfigTest.php +++ b/tests/Feature/ProjectConfig/ProjectConfigTest.php @@ -4,12 +4,15 @@ use CraftCms\Cms\Cms; use CraftCms\Cms\Database\Table; -use CraftCms\Cms\ProjectConfig\Data\ReadOnlyProjectConfigData; use CraftCms\Cms\ProjectConfig\Events\ChangesApplied; +use CraftCms\Cms\ProjectConfig\Events\ConfigEvent; use CraftCms\Cms\ProjectConfig\Events\ItemAdded; use CraftCms\Cms\ProjectConfig\Events\ItemRemoved; use CraftCms\Cms\ProjectConfig\Events\ItemUpdated; +use CraftCms\Cms\ProjectConfig\Events\YamlFilesWritten; +use CraftCms\Cms\ProjectConfig\Exceptions\BusyResourceException; use CraftCms\Cms\ProjectConfig\Exceptions\ReadonlyException; +use CraftCms\Cms\ProjectConfig\Exceptions\StaleResourceException; use CraftCms\Cms\ProjectConfig\ProjectConfig; use CraftCms\Cms\Support\Facades\Path; use CraftCms\Cms\Support\Str; @@ -34,7 +37,7 @@ function getFakeProjectConfig(?array $internal = null, ?array $external = null): $projectConfig = app(ProjectConfig::class); $reflectionClass = new ReflectionClass($projectConfig); - $reflectionClass->getProperty('_internalConfig')->setValue($projectConfig, new ReadOnlyProjectConfigData($internal ?? [ + $reflectionClass->getProperty('current')->setValue($projectConfig, $internal ?? [ 'a' => 'b', 'b' => [ 'c' => 'd', @@ -43,16 +46,16 @@ function getFakeProjectConfig(?array $internal = null, ?array $external = null): 'f' => 'g', 'randomString' => 'Entirely random', 'dateModified' => 1609452000, - ], $projectConfig)); + ]); - $reflectionClass->getProperty('_externalConfig')->setValue($projectConfig, new ReadOnlyProjectConfigData($external ?? [ + $reflectionClass->getProperty('external')->setValue($projectConfig, $external ?? [ 'aa' => 'bb', 'bb' => [ 'vc' => 'dd', ], 'ee' => [11, 22, 33], 'f' => 'g', - ], $projectConfig)); + ]); return $projectConfig; } @@ -128,6 +131,63 @@ function getFakeProjectConfig(?array $internal = null, ?array $external = null): ['f', null], ]); +it('allows forced unchanged values without requiring write access', function (bool $readOnly, bool $locked) { + $projectConfig = getFakeProjectConfig(['test' => 'value', 'dateModified' => 1609452000]); + $projectConfig->readOnly = $readOnly; + $lock = Cache::lock(ProjectConfig::MUTEX_NAME, 30); + + if ($locked) { + expect($lock->get())->toBeTrue(); + } + + try { + expect($projectConfig->set('test', 'value', force: true))->toBeTrue(); + expect($projectConfig->get('test'))->toBe('value'); + expect($projectConfig->get('dateModified'))->toBe(1609452000); + expect($projectConfig->getAppliedChanges())->toBe([]); + } finally { + $lock->release(); + $projectConfig->reset(); + } +})->with([false, true])->with([false, true]); + +it('finds child config arrays without returning the config root', function () { + $config = ['first' => ['nested' => ['value' => 1]], 'second' => [], 'scalar' => true]; + $projectConfig = getFakeProjectConfig($config); + + expect($projectConfig->find(fn (array $item): bool => true))->toBe([ + 'first' => $config['first'], + 'second' => [], + ]); +}); + +it('loads descendant values over stale scalar ancestor rows', function (string $ancestorValue) { + $projectConfig = app(ProjectConfig::class); + DB::table(Table::PROJECTCONFIG)->insert([ + ['path' => 'legacyOverlap', 'value' => $ancestorValue], + ['path' => 'legacyOverlap.deep.value', 'value' => '1'], + ]); + Cache::forget(ProjectConfig::STORED_CACHE_KEY); + $projectConfig->reset(); + + expect($projectConfig->get('legacyOverlap'))->toBe(['deep' => ['value' => 1]]); +})->with(['string' => '"obsolete"', 'integer' => '1', 'boolean' => 'false']); + +it('replays forced external changes against the original config', function (bool $changed) { + $original = ['value' => 'old']; + $incoming = ['value' => $changed ? 'new' : 'old']; + $projectConfig = getFakeProjectConfig(['plugin' => $original], ['plugin' => $incoming]); + $handled = []; + $projectConfig->onUpdate('plugin', function (ConfigEvent $event) use (&$handled) { + $handled[] = [$event->oldValue, $event->newValue]; + }); + + $projectConfig->processConfigChanges('plugin', true); + $projectConfig->processConfigChanges('plugin', true); + + expect($handled)->toBe($changed ? [[$original, $incoming], [$original, $incoming]] : []); +})->with([true, false]); + test('setting value modifies timestamp', function () { $projectConfig = getFakeProjectConfig(); $path = 'randomString'; @@ -215,6 +275,121 @@ function getFakeProjectConfig(?array $internal = null, ?array $external = null): expect(DB::table(Table::PROJECTCONFIG)->where('path', 'rollback-test')->value('value'))->toBe('"value"'); }); +it('applies parent changes after processing descendants', function (array $incoming) { + $projectConfig = getFakeProjectConfig([ + 'test' => ['nested' => ['value' => 1, 'keep' => true], 'direct' => 1], + ], $incoming); + + $projectConfig->applyConfigChanges($incoming); + + expect($projectConfig->get())->toBe($incoming); +})->with([ + 'updates' => [['test' => ['nested' => ['value' => 2, 'keep' => true], 'direct' => 2]]], + 'removals' => [['test' => ['nested' => ['keep' => true]]]], +]); + +it('ignores parent handlers for unrelated event types when applying sibling changes', function () { + $incoming = ['test' => ['one' => ['value' => 2], 'two' => ['value' => 2]]]; + $projectConfig = getFakeProjectConfig([ + 'test' => ['one' => ['value' => 1], 'two' => ['value' => 1]], + ], $incoming); + $handled = []; + $projectConfig->onRemove('test', function () { + throw new RuntimeException('Unexpected removal'); + }); + $projectConfig->onUpdate('test.{uid}', function (ConfigEvent $event) use (&$handled) { + $handled[] = $event->path; + }); + + $projectConfig->applyConfigChanges($incoming); + + expect($handled)->toBe(['test.one', 'test.two']); +}); + +it('preserves list ordering after saving an indexed edit and reloading', function () { + $projectConfig = app(ProjectConfig::class); + $projectConfig->set('testList', range(0, 11)); + $projectConfig->saveModifiedConfigData(); + $projectConfig->set('testList.1', 100); + $projectConfig->saveModifiedConfigData(); + $projectConfig->reset(); + + $expected = [0, 100, 2, 3, 4, 5, 6, 7, 8, 9, 10, 11]; + expect($projectConfig->get('testList'))->toBe($expected); + expect($projectConfig->set('testList', $expected))->toBeFalse(); +}); + +it('guards name mapping mutations against concurrent changes', function (bool $remove, bool $stale) { + $uid = 'd62a289c-4a1a-4e4b-955e-3cf9973454e4'; + $projectConfig = getFakeProjectConfig(['meta' => ['__names__' => [$uid => 'Original']]]); + $lock = Cache::lock(ProjectConfig::MUTEX_NAME, 30); + + if ($stale) { + DB::table(Table::INFO)->update(['configVersion' => 'changed']); + } else { + expect($lock->get())->toBeTrue(); + } + + try { + $change = $remove + ? fn () => $projectConfig->removeNameMapping($uid) + : fn () => $projectConfig->setNameMapping($uid, 'Updated'); + + expect($change)->toThrow($stale ? StaleResourceException::class : BusyResourceException::class); + expect($projectConfig->get("meta.__names__.$uid"))->toBe('Original'); + } finally { + $lock->release(); + $projectConfig->reset(); + } +})->with([ + 'set while locked' => [false, false], + 'remove while locked' => [true, false], + 'set with stale config' => [false, true], + 'remove with stale config' => [true, true], +]); + +it('keeps name mapping changes out of timestamps and yaml exports', function (bool $remove) { + $uid = 'd62a289c-4a1a-4e4b-955e-3cf9973454e4'; + $projectConfig = getFakeProjectConfig([ + 'meta' => ['__names__' => [$uid => 'Original']], + 'dateModified' => 1609452000, + ]); + Event::fake([YamlFilesWritten::class]); + + if ($remove) { + $projectConfig->removeNameMapping($uid); + } else { + $projectConfig->setNameMapping($uid, 'Updated'); + } + + $projectConfig->flush(); + + expect($projectConfig->get("meta.__names__.$uid"))->toBe($remove ? null : 'Updated'); + expect($projectConfig->get('dateModified'))->toBe(1609452000); + Event::assertNotDispatched(YamlFilesWritten::class); +})->with([false, true]); + +it('applies user settings and group changes together', function (bool $existing) { + $projectConfig = app(ProjectConfig::class); + $uid = 'd62a289c-4a1a-4e4b-955e-3cf9973454e4'; + $projectConfig->set('users.allowPublicRegistration', false); + + if ($existing) { + $projectConfig->set("users.groups.$uid", ['name' => 'Original group', 'handle' => 'configTestGroup']); + } + + $incoming = $projectConfig->get(); + $incoming['users']['allowPublicRegistration'] = true; + $incoming['users']['groups'][$uid] = ['name' => 'Updated group', 'handle' => 'configTestGroup']; + + $projectConfig->applyConfigChanges($incoming); + $projectConfig->saveModifiedConfigData(); + + expect($projectConfig->get('users.allowPublicRegistration'))->toBeTrue(); + expect(DB::table(Table::PROJECTCONFIG)->where('path', 'users.allowPublicRegistration')->value('value'))->toBe('true'); + expect(DB::table(Table::USERGROUPS)->where('uid', $uid)->value('name'))->toBe('Updated group'); +})->with(['new group' => false, 'existing group' => true]); + it('starts each config application with fresh path claims', function (bool $fail) { $projectConfig = getFakeProjectConfig(['test' => true], ['test' => true]); $claims = []; diff --git a/tests/Unit/ProjectConfig/ConfigStorageTest.php b/tests/Unit/ProjectConfig/ConfigStorageTest.php new file mode 100644 index 00000000000..5eca6cdcdaa --- /dev/null +++ b/tests/Unit/ProjectConfig/ConfigStorageTest.php @@ -0,0 +1,99 @@ +directory = storage_path('framework/testing/project-config/'.uniqid('', true)); + Path::shouldReceive('config')->with('project')->andReturn($this->directory.'/project'); + Path::shouldReceive('configDelta')->with('delta.yaml')->andReturn($this->directory.'/deltas/delta.yaml'); +}); + +afterEach(function () { + File::deleteDirectory($this->directory); +}); + +it('returns empty config before installation', function () { + Context::addHidden('craft.isInstalled', false); + + expect(new ConfigStorage()->readDatabase(null))->toBe([]); +}); + +it('strips filename handles only when followed by a UUID', function (string $filename, string $key) { + File::writeToFile($this->directory.'/project/project.yaml', '{}'); + File::writeToFile($this->directory.'/project/fields/'.$filename.'.yaml', 'name: Example'); + + expect(new ConfigStorage()->readYaml('project'))->toBe(['fields' => [$key => ['name' => 'Example']]]); +})->with([ + 'lowercase UUID' => ['example--d62a289c-4a1a-4e4b-955e-3cf9973454e4', 'd62a289c-4a1a-4e4b-955e-3cf9973454e4'], + 'uppercase UUID' => ['example--D62A289C-4A1A-4E4B-955E-3CF9973454E4', 'D62A289C-4A1A-4E4B-955E-3CF9973454E4'], + 'invalid UUID' => ['example--0123456789abcdef0123456789abcdef0123', 'example--0123456789abcdef0123456789abcdef0123'], +]); + +it('annotates YAML with the first UUID name on each line', function (string $uid) { + $otherUid = '6fa459ea-ee8a-4ca4-894e-db77e160355e'; + $storage = new ConfigStorage; + $storage->writeYaml('project', [ + 'example' => "$uid $otherUid", + 'meta' => ['__names__' => [$uid => 'First component', $otherUid => 'Second component']], + ]); + + expect(File::get($this->directory.'/project/project.yaml'))->toContain("example: '$uid $otherUid' # First component"); +})->with(['d62a289c-4a1a-4e4b-955e-3cf9973454e4', 'D62A289C-4A1A-4E4B-955E-3CF9973454E4']); + +it('preserves exported files when a yaml written listener fails', function () { + $storage = new ConfigStorage; + $config = ['system' => ['name' => 'Example']]; + Event::listen(YamlFilesWritten::class, function () { + throw new RuntimeException('Listener failed'); + }); + + expect(fn () => $storage->writeYaml('project', $config))->toThrow(RuntimeException::class, 'Listener failed'); + + expect($storage->readYaml('project'))->toBe($config); + expect(Cache::has(ProjectConfig::FILE_ISSUES_CACHE_KEY))->toBeFalse(); +}); + +it('preserves hidden files and directories when replacing YAML exports', function () { + File::writeToFile($this->directory.'/project/.gitignore', 'keep'); + File::writeToFile($this->directory.'/project/.hidden/file.txt', 'keep'); + File::writeToFile($this->directory.'/project/obsolete/file.yaml', 'obsolete: true'); + + $storage = new ConfigStorage; + $storage->writeYaml('project', ['system' => ['name' => 'Example']]); + + expect(File::get($this->directory.'/project/.gitignore'))->toBe('keep'); + expect(File::get($this->directory.'/project/.hidden/file.txt'))->toBe('keep'); + expect(File::exists($this->directory.'/project/obsolete'))->toBeFalse(); + expect($storage->readYaml('project'))->toBe(['system' => ['name' => 'Example']]); +}); + +it('uses configured Unix permissions for export and delta directories', function () { + $mode = Cms::config()->defaultDirMode; + $mask = umask(0); + Cms::config()->defaultDirMode = 0700; + $storage = new ConfigStorage; + + try { + $storage->writeYaml('project', [ + 'fields' => ['d62a289c-4a1a-4e4b-955e-3cf9973454e4' => ['name' => 'Example']], + ]); + $storage->writeDelta([['added' => ['test' => true]]], 1); + + expect(fileperms($this->directory.'/project') & 0777)->toBe(0700); + expect(fileperms($this->directory.'/project/fields') & 0777)->toBe(0700); + expect(fileperms($this->directory.'/deltas') & 0777)->toBe(0700); + } finally { + Cms::config()->defaultDirMode = $mode; + umask($mask); + } +})->skipOnWindows(); diff --git a/tests/Unit/ProjectConfig/ProjectConfigTest.php b/tests/Unit/ProjectConfig/ProjectConfigTest.php index 5d6473a07e0..6f381d2a849 100644 --- a/tests/Unit/ProjectConfig/ProjectConfigTest.php +++ b/tests/Unit/ProjectConfig/ProjectConfigTest.php @@ -2,7 +2,6 @@ declare(strict_types=1); -use CraftCms\Cms\ProjectConfig\Data\ReadOnlyProjectConfigData; use CraftCms\Cms\ProjectConfig\Events\ConfigEvent; use CraftCms\Cms\ProjectConfig\Events\ItemAdded; use CraftCms\Cms\ProjectConfig\Events\ItemRemoved; @@ -48,8 +47,8 @@ $handled = []; $reflection = new ReflectionClass($projectConfig); - $reflection->getProperty('_internalConfig')->setValue($projectConfig, new ReadOnlyProjectConfigData([], $projectConfig)); - $reflection->getProperty('_externalConfig')->setValue($projectConfig, new ReadOnlyProjectConfigData(['plugin' => ['example' => true]], $projectConfig)); + $reflection->getProperty('current')->setValue($projectConfig, []); + $reflection->getProperty('external')->setValue($projectConfig, ['plugin' => ['example' => true]]); $reflection->getProperty('isApplyingExternalChanges')->setValue($projectConfig, true); $projectConfig->onAdd('plugin.{uid}', function () use (&$handled) { diff --git a/tests/Unit/Support/FileTest.php b/tests/Unit/Support/FileTest.php index 45ce5865ee2..92acf075e1f 100644 --- a/tests/Unit/Support/FileTest.php +++ b/tests/Unit/Support/FileTest.php @@ -3,6 +3,7 @@ declare(strict_types=1); use CraftCms\Cms\Support\File; +use Illuminate\Filesystem\Filesystem; beforeEach(function () { $this->sandboxPath = storage_path('framework/testing/file-test/'.uniqid('', true)); @@ -420,6 +421,25 @@ }); describe('cleanDirectory', function () { + test('reports failed deletions when cleaning with exclusions', function (bool $directory) { + $path = $this->sandboxPath.DIRECTORY_SEPARATOR.'blocked'; + + if ($directory) { + mkdir($path); + } else { + file_put_contents($path, 'content'); + } + + $this->partialMock(Filesystem::class) + ->shouldReceive($directory ? 'deleteDirectory' : 'delete') + ->with($path) + ->once() + ->andReturn(false); + + expect(File::cleanDirectory($this->sandboxPath, except: ['.*']))->toBeFalse(); + expect(file_exists($path))->toBeTrue(); + })->with(['file' => false, 'directory' => true]); + test('removes all contents', function () { $dir = $this->sandboxPath.'/clean-test'; File::makeDirectory($dir); diff --git a/yii2-adapter/legacy/services/ProjectConfig.php b/yii2-adapter/legacy/services/ProjectConfig.php index 4f9927661a7..20179eca4c7 100644 --- a/yii2-adapter/legacy/services/ProjectConfig.php +++ b/yii2-adapter/legacy/services/ProjectConfig.php @@ -22,7 +22,6 @@ use CraftCms\Cms\Shared\Exceptions\NotSupportedException; use CraftCms\DependencyAwareCache\Dependency\CallbackDependency; use Illuminate\Support\Facades\Event; -use ReflectionClass; use Throwable; use yii\base\Component; use yii\base\ErrorException; @@ -522,11 +521,7 @@ public function saveModifiedConfigData(): void */ public function getPendingChangeSummary(): array { - /** - * Call the private method to get the pending changes. - */ - $reflectionMethod = new ReflectionClass(\CraftCms\Cms\ProjectConfig\ProjectConfig::class)->getMethod('_getPendingChanges'); - $pendingChanges = $reflectionMethod->invoke(app(\CraftCms\Cms\ProjectConfig\ProjectConfig::class)); + $pendingChanges = app(\CraftCms\Cms\ProjectConfig\ProjectConfig::class)->getPendingChanges(); $summary = [];