CheckpointStorage.php

Namespace

Drupal\Core\Config\Checkpoint

File

core/lib/Drupal/Core/Config/Checkpoint/CheckpointStorage.php

View source
<?php

declare (strict_types=1);
namespace Drupal\Core\Config\Checkpoint;

use Drupal\Core\Config\Config;
use Drupal\Core\Config\ConfigCollectionEvents;
use Drupal\Core\Config\ConfigCrudEvent;
use Drupal\Core\Config\ConfigEvents;
use Drupal\Core\Config\ConfigRenameEvent;
use Drupal\Core\Config\StorableConfigBase;
use Drupal\Core\Config\StorageInterface;
use Drupal\Core\KeyValueStore\KeyValueFactoryInterface;
use Drupal\Core\KeyValueStore\KeyValueStoreInterface;
use Psr\Log\LoggerAwareInterface;
use Psr\Log\LoggerAwareTrait;
use Symfony\Component\EventDispatcher\EventSubscriberInterface;

/**
 * Provides a config storage that can make checkpoints.
 *
 * This storage wraps the active storage, and provides the ability to take
 * checkpoints. Once a checkpoint has been created all configuration operations
 * made after the checkpoint will be recorded, so it is possible to revert to
 * original state when the checkpoint was taken.
 *
 * This class cannot be used to checkpoint another storage since it relies on
 * events triggered by the configuration system in order to work. It is the
 * responsibility of the caller to construct this class with the active storage.
 *
 * @internal
 *   This API is experimental.
 */
final class CheckpointStorage implements CheckpointStorageInterface, EventSubscriberInterface, LoggerAwareInterface {
    use LoggerAwareTrait;
    
    /**
     * Used as prefix to a config checkpoint collection.
     *
     * If this code is copied in order to checkpoint a different storage then
     * this value must be changed.
     */
    private const KEY_VALUE_COLLECTION_PREFIX = 'config.checkpoint.';
    
    /**
     * Used to store the list of collections in each checkpoint.
     *
     * Note this cannot be a valid configuration name.
     *
     * @see \Drupal\Core\Config\ConfigBase::validateName()
     */
    private const CONFIG_COLLECTION_KEY = 'collections';
    
    /**
     * The key value stores that store configuration changed for each checkpoint.
     *
     * @var \Drupal\Core\KeyValueStore\KeyValueStoreInterface[]
     */
    private array $keyValueStores;
    
    /**
     * The checkpoint to read from.
     *
     * @var \Drupal\Core\Config\Checkpoint\Checkpoint|null
     */
    private ?Checkpoint $readFromCheckpoint = NULL;
    
    /**
     * Constructs a CheckpointStorage object.
     *
     * @param \Drupal\Core\Config\StorageInterface $activeStorage
     *   The active configuration storage.
     * @param \Drupal\Core\Config\Checkpoint\CheckpointListInterface $checkpoints
     *   The list of checkpoints.
     * @param \Drupal\Core\KeyValueStore\KeyValueFactoryInterface $keyValueFactory
     *   The key value factory.
     * @param string $collection
     *   (optional) The configuration collection.
     */
    public function __construct(StorageInterface $activeStorage, CheckpointListInterface $checkpoints, KeyValueFactoryInterface $keyValueFactory, string $collection = StorageInterface::DEFAULT_COLLECTION) {
    }
    
    /**
     * {@inheritdoc}
     */
    public function exists($name) {
        if (count($this->checkpoints) === 0) {
            throw new NoCheckpointsException();
        }
        foreach ($this->getCheckpointsToReadFrom() as $checkpoint) {
            $in_checkpoint = $this->getKeyValue($checkpoint->id, $this->collection)
                ->get($name);
            if ($in_checkpoint !== NULL) {
                // If $in_checkpoint is FALSE then the configuration has been deleted.
                return $in_checkpoint !== FALSE;
            }
        }
        return $this->activeStorage
            ->exists($name);
    }
    
    /**
     * {@inheritdoc}
     */
    public function read($name) {
        $return = $this->readMultiple([
            $name,
        ]);
        return $return[$name] ?? FALSE;
    }
    
    /**
     * {@inheritdoc}
     */
    public function readMultiple(array $names) {
        if (count($this->checkpoints) === 0) {
            throw new NoCheckpointsException();
        }
        $return = [];
        foreach ($this->getCheckpointsToReadFrom() as $checkpoint) {
            $return = array_merge($return, $this->getKeyValue($checkpoint->id, $this->collection)
                ->getMultiple($names));
            // Remove the read names from the list to fetch.
            $names = array_diff($names, array_keys($return));
            if (empty($names)) {
                // All the configuration has been read. Nothing more to do.
                break;
            }
        }
        // Names not found in the checkpoints have not been modified: read from
        // active storage.
        if (!empty($names)) {
            $return = array_merge($return, $this->activeStorage
                ->readMultiple($names));
        }
        // Remove any renamed or new configuration (FALSE has been recorded for
        // these operations in the checkpoint).
        // @see ::onConfigRename()
        // @see ::onConfigSaveAndDelete()
        return array_filter($return);
    }
    
    /**
     * {@inheritdoc}
     */
    public function encode($data) {
        return $this->activeStorage
            ->encode($data);
    }
    
    /**
     * {@inheritdoc}
     */
    public function decode($raw) {
        return $this->activeStorage
            ->decode($raw);
    }
    
    /**
     * {@inheritdoc}
     */
    public function listAll($prefix = '') {
        if (count($this->checkpoints) === 0) {
            throw new NoCheckpointsException();
        }
        $names = $new_configuration = [];
        foreach ($this->getCheckpointsToReadFrom() as $checkpoint) {
            $checkpoint_names = array_keys(array_filter($this->getKeyValue($checkpoint->id, $this->collection)
                ->getAll(), function (mixed $value, string $name) use (&$new_configuration, $prefix) {
                if ($name === static::CONFIG_COLLECTION_KEY) {
                    return FALSE;
                }
                // Remove any that don't start with the prefix.
                if ($prefix !== '' && !str_starts_with($name, $prefix)) {
                    return FALSE;
                }
                // We've determined in a previous checkpoint that the configuration did
                // not exist.
                if (in_array($name, $new_configuration, TRUE)) {
                    return FALSE;
                }
                // If the value is FALSE then the configuration was created after the
                // checkpoint.
                if ($value === FALSE) {
                    $new_configuration[] = $name;
                    return FALSE;
                }
                return TRUE;
            }, ARRAY_FILTER_USE_BOTH));
            $names = array_merge($names, $checkpoint_names);
        }
        // Remove any names that did not exist prior to the checkpoint.
        $active_names = array_diff($this->activeStorage
            ->listAll($prefix), $new_configuration);
        $names = array_unique(array_merge($names, $active_names));
        sort($names);
        return $names;
    }
    
    /**
     * {@inheritdoc}
     */
    public function createCollection($collection) {
        $collection = new self($this->activeStorage
            ->createCollection($collection), $this->checkpoints, $this->keyValueFactory, $collection);
        // \Drupal\Core\Config\Checkpoint\CheckpointStorage::$readFromCheckpoint is
        // assigned by reference so that it is  consistent across all collection
        // objects created from the same initial object.
        $collection->readFromCheckpoint =& $this->readFromCheckpoint;
        return $collection;
    }
    
    /**
     * {@inheritdoc}
     */
    public function getAllCollectionNames() {
        $names = [];
        foreach ($this->getCheckpointsToReadFrom() as $checkpoint) {
            $names = array_merge($names, $this->getKeyValue($checkpoint->id, StorageInterface::DEFAULT_COLLECTION)
                ->get(static::CONFIG_COLLECTION_KEY, []));
        }
        return array_unique(array_merge($this->activeStorage
            ->getAllCollectionNames(), $names));
    }
    
    /**
     * {@inheritdoc}
     */
    public function getCollectionName() {
        return $this->collection;
    }
    
    /**
     * {@inheritdoc}
     */
    public function checkpoint(string|\Stringable $label) : Checkpoint {
        // Generate a new ID based on the state of the current active checkpoint.
        $active_checkpoint = $this->checkpoints
            ->getActiveCheckpoint();
        if (!$active_checkpoint instanceof Checkpoint) {
            // @todo https://www.drupal.org/i/3408525 Consider options for generating
            //   a real fingerprint.
            $id = hash('sha1', random_bytes(32));
            return $this->checkpoints
                ->add($id, $label);
        }
        // Determine if we need to create a new checkpoint by checking if
        // configuration has changed since the last checkpoint.
        $collections = $this->getAllCollectionNames();
        $collections[] = StorageInterface::DEFAULT_COLLECTION;
        foreach ($collections as $collection) {
            $current_checkpoint_data[$collection] = $this->getKeyValue($active_checkpoint->id, $collection)
                ->getAll();
            // Remove the collections key because it is irrelevant.
            unset($current_checkpoint_data[$collection][static::CONFIG_COLLECTION_KEY]);
            // If there is no data in the collection then there is no need to hash
            // the empty array.
            if (empty($current_checkpoint_data[$collection])) {
                unset($current_checkpoint_data[$collection]);
            }
        }
        if (!empty($current_checkpoint_data)) {
            // Use json_encode() here because it is both quicker and results in
            // smaller output than serialize().
            $id = hash('sha1', ($active_checkpoint->parent ?? '') . json_encode($current_checkpoint_data));
            return $this->checkpoints
                ->add($id, $label);
        }
        $this->logger?->notice('A backup checkpoint was not created because nothing has changed since the "{active}" checkpoint was created.', [
            'active' => $active_checkpoint->label,
        ]);
        return $active_checkpoint;
    }
    
    /**
     * {@inheritdoc}
     */
    public function setCheckpointToReadFrom(string|Checkpoint $checkpoint_id) : static {
        if ($checkpoint_id instanceof Checkpoint) {
            $checkpoint_id = $checkpoint_id->id;
        }
        $this->readFromCheckpoint = $this->checkpoints
            ->get($checkpoint_id);
        return $this;
    }
    
    /**
     * Gets the key value storage for the provided checkpoint.
     *
     * @param string $checkpoint
     *   The checkpoint to get the key value storage for.
     * @param string $collection
     *   The config collection to get the key value storage for.
     *
     * @return \Drupal\Core\KeyValueStore\KeyValueStoreInterface
     *   The key value storage for the provided checkpoint.
     */
    private function getKeyValue(string $checkpoint, string $collection) : KeyValueStoreInterface {
        $checkpoint_key = $checkpoint;
        if ($collection !== StorageInterface::DEFAULT_COLLECTION) {
            $checkpoint_key = $collection . '.' . $checkpoint_key;
        }
        return $this->keyValueStores[$checkpoint_key] ??= $this->keyValueFactory
            ->get(self::KEY_VALUE_COLLECTION_PREFIX . $checkpoint_key);
    }
    
    /**
     * Gets the checkpoints to read from.
     *
     * @return \Traversable<string, \Drupal\Core\Config\Checkpoint\Checkpoint>
     *   The checkpoints, keyed by ID.
     */
    private function getCheckpointsToReadFrom() : \Traversable {
        $checkpoint = $this->checkpoints
            ->getActiveCheckpoint();
        
        /** @var \Drupal\Core\Config\Checkpoint\Checkpoint[] $checkpoints_to_read_from */
        $checkpoints_to_read_from = [
            $checkpoint,
        ];
        if ($checkpoint->id !== $this->readFromCheckpoint?->id) {
            // Follow ancestors to find the checkpoint to start reading from.
            foreach ($this->checkpoints
                ->getParents($checkpoint->id) as $checkpoint) {
                array_unshift($checkpoints_to_read_from, $checkpoint);
                if ($checkpoint->id === $this->readFromCheckpoint?->id) {
                    break;
                }
            }
        }
        // Replay in parent to child order.
        foreach ($checkpoints_to_read_from as $checkpoint) {
            (yield $checkpoint->id => $checkpoint);
        }
    }
    
    /**
     * Updates checkpoint when configuration is saved.
     *
     * @param \Drupal\Core\Config\ConfigCrudEvent $event
     *   The configuration event.
     */
    public function onConfigSaveAndDelete(ConfigCrudEvent $event) : void {
        $active_checkpoint = $this->checkpoints
            ->getActiveCheckpoint();
        if ($active_checkpoint === NULL) {
            return;
        }
        $saved_config = $event->getConfig();
        $collection = $saved_config->getStorage()
            ->getCollectionName();
        $this->storeCollectionName($collection);
        $key_value = $this->getKeyValue($active_checkpoint->id, $collection);
        // If we have not yet stored a checkpoint for this configuration we should.
        if ($key_value->get($saved_config->getName()) === NULL) {
            $original_data = $this->getOriginalConfig($saved_config);
            // An empty array indicates that the config has to be new as a sequence
            // cannot be the root of a config object. We need to make this assumption
            // because $saved_config->isNew() will always return FALSE here.
            if (empty($original_data)) {
                $original_data = FALSE;
            }
            // Only save change to state if there is a change, even if it's just keys
            // being re-ordered.
            if ($original_data !== $saved_config->getRawData()) {
                $key_value->set($saved_config->getName(), $original_data);
            }
        }
    }
    
    /**
     * Updates checkpoint when configuration is saved.
     *
     * @param \Drupal\Core\Config\ConfigRenameEvent $event
     *   The configuration event.
     */
    public function onConfigRename(ConfigRenameEvent $event) : void {
        $active_checkpoint = $this->checkpoints
            ->getActiveCheckpoint();
        if ($active_checkpoint === NULL) {
            return;
        }
        $collection = $event->getConfig()
            ->getStorage()
            ->getCollectionName();
        $this->storeCollectionName($collection);
        $key_value = $this->getKeyValue($active_checkpoint->id, $collection);
        $old_name = $event->getOldName();
        // If we have not yet stored a checkpoint for this configuration, store a
        // complete copy of the original configuration. Note that renames do not
        // change data but storing the complete data allows
        // \Drupal\Core\Config\ConfigImporter to track renames using UUIDs.
        if ($key_value->get($old_name) === NULL) {
            $key_value->set($old_name, $this->getOriginalConfig($event->getConfig()));
        }
        // Record that the new name did not exist prior to the checkpoint.
        $new_name = $event->getConfig()
            ->getName();
        if ($key_value->get($new_name) === NULL) {
            $key_value->set($new_name, FALSE);
        }
    }
    
    /**
     * Gets the original data from the configuration.
     *
     * @param \Drupal\Core\Config\StorableConfigBase $config
     *   The config to get the original data from.
     *
     * @return mixed
     *   The original data.
     */
    private function getOriginalConfig(StorableConfigBase $config) : mixed {
        if ($config instanceof Config) {
            return $config->getOriginal(apply_overrides: FALSE);
        }
        return $config->getOriginal();
    }
    
    /**
     * Stores the collection name so the storage knows its own collections.
     *
     * @param string $collection
     *   The name of the collection.
     */
    private function storeCollectionName(string $collection) : void {
        // We do not need to store the default collection.
        if ($collection === StorageInterface::DEFAULT_COLLECTION) {
            return;
        }
        $key_value = $this->getKeyValue($this->checkpoints
            ->getActiveCheckpoint()->id, StorageInterface::DEFAULT_COLLECTION);
        $collections = $key_value->get(static::CONFIG_COLLECTION_KEY, []);
        assert(is_array($collections));
        if (in_array($collection, $collections, TRUE)) {
            return;
        }
        $collections[] = $collection;
        $key_value->set(static::CONFIG_COLLECTION_KEY, $collections);
    }
    
    /**
     * {@inheritdoc}
     */
    public static function getSubscribedEvents() : array {
        $events[ConfigEvents::SAVE][] = 'onConfigSaveAndDelete';
        $events[ConfigEvents::DELETE][] = 'onConfigSaveAndDelete';
        $events[ConfigEvents::RENAME][] = 'onConfigRename';
        $events[ConfigCollectionEvents::SAVE_IN_COLLECTION][] = 'onConfigSaveAndDelete';
        $events[ConfigCollectionEvents::DELETE_IN_COLLECTION][] = 'onConfigSaveAndDelete';
        $events[ConfigCollectionEvents::RENAME_IN_COLLECTION][] = 'onConfigRename';
        return $events;
    }
    
    /**
     * {@inheritdoc}
     */
    public function write($name, array $data) : never {
        throw new \BadMethodCallException(__METHOD__ . ' is not allowed on a CheckpointStorage');
    }
    
    /**
     * {@inheritdoc}
     */
    public function delete($name) : never {
        throw new \BadMethodCallException(__METHOD__ . ' is not allowed on a CheckpointStorage');
    }
    
    /**
     * {@inheritdoc}
     */
    public function rename($name, $new_name) : never {
        throw new \BadMethodCallException(__METHOD__ . ' is not allowed on a CheckpointStorage');
    }
    
    /**
     * {@inheritdoc}
     */
    public function deleteAll($prefix = '') : never {
        throw new \BadMethodCallException(__METHOD__ . ' is not allowed on a CheckpointStorage');
    }

}

Classes

Title Deprecated Summary
CheckpointStorage Provides a config storage that can make checkpoints.

Buggy or inaccurate documentation? Please file an issue. Need support? Need help programming? Connect with the Drupal community.