Skip to content
Snippets Groups Projects
Verified Commit 1beb140e authored by Dave Long's avatar Dave Long
Browse files

Issue #3439923 by alexpott, longwave, thejimbirch, Wim Leers, phenaproxima,...

Issue #3439923 by alexpott, longwave, thejimbirch, Wim Leers, phenaproxima, immaculatexavier, nedjo, bircher, deviantintegral, franz, narendraR, omkar.podey, srishtiiee, Rajab Natshah, millnut, mondrake, amateescu, larowlan, sonfd, tasc, vasike: Add recipes api as experimental API to core

(cherry picked from commit 74da82a3)
parent dbe8913a
No related branches found
No related tags found
42 merge requests!10663Issue #3495778: Update phpdoc in FileSaveHtaccessLoggingTest,!10451Issue #3472458 by watergate, smustgrave: CKEditor 5 show blocks label is not translated,!103032838547 Fix punctuation rules for inline label suffix colon with CSS only,!10150Issue #3467294 by quietone, nod_, smustgrave, catch, longwave: Change string...,!10130Resolve #3480321 "Second level menu",!9936Issue #3483087: Check the module:// prefix in the translation server path and replace it with the actual module path,!9933Issue #3394728 by ankondrat4: Undefined array key "#prefix" and deprecated function: explode() in Drupal\file\Element\ManagedFile::uploadAjaxCallback(),!9914Issue #3451136 by quietone, gapple, ghost of drupal past: Improve...,!9882Draft: Issue #3481777 In bulk_form ensure the triggering element is the bulk_form button,!9839Issue #3445469 by pooja_sharma, smustgrave: Add additional test coverage for...,!9815Issue #3480025: There is no way to remove entity cache items,!9757Issue #3478869 Add "All" or overview links to parent links,!9752Issue #3439910 by pooja_sharma, vensires: Fix Toolbar tests that rely on UID1's super user behavior,!9749Issue #3439910 by pooja_sharma, vensires: Fix Toolbar tests that rely on UID1's super user behavior,!9678Issue #3465132 by catch, Spokje, nod_: Show test run time by class in run-tests.sh output,!9578Issue #3304746 by scott_euser, casey, smustgrave: BigPipe cannot handle (GET)...,!9449Issue #3344041: Allow textarea widgets to be used for text (formatted) fields,!8945🔥🔥🔥🔥🔥🔥🔥🔥🔥🔥🔥🔥🔥🔥🔥🔥🔥🔥🔥🔥🔥🔥🔥🔥🔥🔥🔥🔥🔥🔥🔥🔥🔥🔥🔥🔥🔥🔥🔥🔥🔥🔥🔥🔥🔥🔥🔥🔥🔥🔥🔥🔥🔥🔥🔥🔥🔥🔥🔥🔥🔥🔥🔥🔥🔥🔥🔥🔥🔥🔥🔥🔥🔥🔥🔥🔥🔥🔥...,!8893Resolve #3444391 "Navigation center sm logo",!8772Issue #3445909 by seanB, smustgrave, alexpott, catch: Add static caching to...,!8723Make dblog entities,!8708Filter out disabled display extenders on save,!8691#3459116 - Update Manager Settings: Validate same email address added multiple times,!8665Issue #3449743 by catch: Try to optimize test ordering when run-tests.sh is...,!8598Draft: Issue #3458215: Migrate Toolbar button to SDC,!8572Reorder checkboxes on "Development settings" page,!8538Issue #3457009: Fixing xxception message thrown.,!8516Update file EntityReferenceItem.php,!8505Issue #3456528: _node_mass_update_batch_process fails during user cancel when revision is deleted,!8494Issue #3452511: Convert ProviderRepositoryTest to a kernel test,!8463Prevent re-install if site already exists,!8392Issue #3454196: Filter placeholders without arguments are not replaced when HTML corrector filter applied afterwards,!8384Issue #3446403 by edutrul, jnicola, mradcliffe: [name]: This field is missing for example recipe,!8304Issue #2990766 by camilledavis, Gauravvvv, mgifford: Location of "Skip to...,!8178Issue #3439909 by SolimanHarkas, vensires: Fix Taxonomy tests that rely on...,!8138Remove inactive initiatives from maintainers list,!8078Issue #3439909 by SolimanHarkas, vensires: Fix Taxonomy tests that rely on...,!8065Adds Default Content and Recipes Subsystems,!8047Issue #3439909 by SolimanHarkas, vensires: Fix Taxonomy tests that rely on...,!7974Issue #3439909 by SolimanHarkas, vensires: Fix Taxonomy tests that rely on...,!7972Issue #3439909 by SolimanHarkas, vensires: Fix Taxonomy tests that rely on...,!5829Porting tabledrag CSS refactor from D10 branch
Pipeline #166241 passed
Showing
with 1259 additions and 29 deletions
......@@ -67,6 +67,17 @@ parameters:
services:
_defaults:
autoconfigure: true
plugin.manager.config_action:
class: Drupal\Core\Config\Action\ConfigActionManager
parent: default_plugin_manager
arguments: ['@config.manager', '@config.storage', '@config.typed', '@config.factory']
Drupal\Core\DefaultContent\Importer:
autowire: true
Drupal\Core\DefaultContent\AdminAccountSwitcher:
arguments:
$isSuperUserAccessEnabled: '%security.enable_super_user%'
autowire: true
public: false
# Simple cache contexts, directly derived from the request context.
cache_context.ip:
class: Drupal\Core\Cache\Context\IpCacheContext
......@@ -385,6 +396,14 @@ services:
public: false
tags:
- { name: backend_overridable }
config.storage.checkpoint:
class: Drupal\Core\Config\Checkpoint\CheckpointStorage
arguments: [ '@config.storage', '@config.checkpoints', '@keyvalue' ]
Drupal\Core\Config\Checkpoint\CheckpointStorageInterface: '@config.storage.checkpoint'
config.checkpoints:
class: Drupal\Core\Config\Checkpoint\LinearHistory
arguments: [ '@state', '@datetime.time' ]
Drupal\Core\Config\Checkpoint\CheckpointListInterface: '@config.checkpoints'
config.import_transformer:
class: Drupal\Core\Config\ImportStorageTransformer
arguments: ['@event_dispatcher', '@database', '@lock', '@lock.persistent']
......
......@@ -26,6 +26,8 @@
use Drupal\Core\Installer\InstallerKernel;
use Drupal\Core\Language\Language;
use Drupal\Core\Language\LanguageManager;
use Drupal\Core\Recipe\Recipe;
use Drupal\Core\Recipe\RecipeRunner;
use Drupal\Core\Site\Settings;
use Drupal\Core\StringTranslation\Translator\FileTranslation;
use Drupal\Core\StackMiddleware\ReverseProxyMiddleware;
......@@ -839,6 +841,27 @@ function install_tasks($install_state) {
array_slice($tasks, $key, NULL, TRUE);
}
if (!empty($install_state['parameters']['recipe'])) {
// The install state indicates that we are installing from a recipe.
$key = array_search('install_profile_modules', array_keys($tasks), TRUE);
unset($tasks['install_profile_modules']);
unset($tasks['install_profile_themes']);
unset($tasks['install_install_profile']);
$recipe_tasks = [
'install_recipe_required_modules' => [
'display_name' => t('Install required modules'),
'type' => 'batch',
],
'install_recipe_batch' => [
'display_name' => t('Install recipe'),
'type' => 'batch',
],
];
$tasks = array_slice($tasks, 0, $key, TRUE) +
$recipe_tasks +
array_slice($tasks, $key, NULL, TRUE);
}
// Now add any tasks defined by the installation profile.
if (!empty($install_state['parameters']['profile'])) {
// Load the profile install file, because it is not always loaded when
......@@ -2539,3 +2562,71 @@ function _install_config_locale_overrides_process_batch(array $names, array $lan
}
$context['finished'] = 1;
}
/**
* Installs required modules prior to applying a recipe via the installer.
*
* @see install_tasks()
*
* @internal
* All installer code is internal.
*/
function install_recipe_required_modules() {
// We need to manually trigger the installation of core-provided entity types,
// as those will not be handled by the module installer.
// @see install_profile_modules()
install_core_entity_type_definitions();
$batch_builder = new BatchBuilder();
$batch_builder
->setFinishCallback([ConfigImporterBatch::class, 'finish'])
->setTitle(t('Installing required modules'))
->setInitMessage(t('Starting required module installation.'))
->setErrorMessage(t('Required module installation has encountered an error.'));
$files = \Drupal::service('extension.list.module')->getList();
// Always install required modules first.
$required = [];
foreach ($files as $module => $extension) {
if (!empty($extension->info['required'])) {
$required[$module] = $extension->sort;
}
}
arsort($required);
// The system module is already installed. See install_base_system().
unset($required['system']);
foreach ($required as $module => $weight) {
$batch_builder->addOperation(
'_install_module_batch',
[$module, $files[$module]->info['name']],
);
}
return $batch_builder->toArray();
}
/**
* Creates a batch for the recipe system to process.
*
* @see install_tasks()
*
* @internal
* This API is experimental.
*/
function install_recipe_batch(&$install_state) {
$batch_builder = new BatchBuilder();
$batch_builder
->setTitle(t('Installing recipe'))
->setInitMessage(t('Starting recipe installation.'))
->setErrorMessage(t('Recipe installation has encountered an error.'));
$recipe = Recipe::createFromDirectory($install_state['parameters']['recipe']);
foreach (RecipeRunner::toBatchOperations($recipe) as $step) {
$batch_builder->addOperation(...$step);
}
return $batch_builder->toArray();
}
......@@ -48,11 +48,12 @@ public function __construct($class_loader) {
protected function configure() {
$this->setName('install')
->setDescription('Installs a Drupal demo site. This is not meant for production and might be too simple for custom development. It is a quick and easy way to get Drupal running.')
->addArgument('install-profile', InputArgument::OPTIONAL, 'Install profile to install the site in.')
->addArgument('install-profile-or-recipe', InputArgument::OPTIONAL, 'Install profile or recipe directory from which to install the site.')
->addOption('langcode', NULL, InputOption::VALUE_OPTIONAL, 'The language to install the site in.', 'en')
->addOption('site-name', NULL, InputOption::VALUE_OPTIONAL, 'Set the site name.', 'Drupal')
->addUsage('demo_umami --langcode fr')
->addUsage('standard --site-name QuickInstall');
->addUsage('standard --site-name QuickInstall')
->addUsage('core/recipes/standard --site-name RecipeBuiltSite');
parent::configure();
}
......@@ -78,15 +79,43 @@ protected function execute(InputInterface $input, OutputInterface $output): int
return 0;
}
$install_profile = $input->getArgument('install-profile');
if ($install_profile && !$this->validateProfile($install_profile, $io)) {
return 1;
}
if (!$install_profile) {
$install_profile_or_recipe = $input->getArgument('install-profile-or-recipe');
if (!$install_profile_or_recipe) {
// User did not provide a recipe or install profile.
$install_profile = $this->selectProfile($io);
}
// Determine if an install profile or a recipe has been provided.
elseif ($this->validateProfile($install_profile_or_recipe)) {
// User provided an install profile.
$install_profile = $install_profile_or_recipe;
}
elseif ($this->validateRecipe($install_profile_or_recipe)) {
// User provided a recipe.
$recipe = $install_profile_or_recipe;
}
else {
$error_msg = sprintf("'%s' is not a valid install profile or recipe.", $install_profile_or_recipe);
return $this->install($this->classLoader, $io, $install_profile, $input->getOption('langcode'), $this->getSitePath(), $input->getOption('site-name'));
// If it does not look like a path make suggestions based upon available
// profiles.
if (!str_contains('/', $install_profile_or_recipe)) {
$alternatives = [];
foreach (array_keys($this->getProfiles(TRUE, FALSE)) as $profile_name) {
$lev = levenshtein($install_profile_or_recipe, $profile_name);
if ($lev <= strlen($profile_name) / 4 || str_contains($profile_name, $install_profile_or_recipe)) {
$alternatives[] = $profile_name;
}
}
if (!empty($alternatives)) {
$error_msg .= sprintf(" Did you mean '%s'?", implode("' or '", $alternatives));
}
}
$io->getErrorStyle()->error($error_msg);
return 1;
}
return $this->install($this->classLoader, $io, $install_profile ?? '', $input->getOption('langcode'), $this->getSitePath(), $input->getOption('site-name'), $recipe ?? '');
}
/**
......@@ -123,6 +152,8 @@ protected function isDrupalInstalled() {
* The path to install the site to, like 'sites/default'.
* @param string $site_name
* The site name.
* @param string $recipe
* The recipe to use for installing.
*
* @throws \Exception
* Thrown when failing to create the $site_path directory or settings.php.
......@@ -130,7 +161,7 @@ protected function isDrupalInstalled() {
* @return int
* The command exit status.
*/
protected function install($class_loader, SymfonyStyle $io, $profile, $langcode, $site_path, $site_name) {
protected function install($class_loader, SymfonyStyle $io, $profile, $langcode, $site_path, $site_name, string $recipe) {
$sqliteDriverNamespace = 'Drupal\\sqlite\\Driver\\Database\\sqlite';
$password = Crypt::randomBytesBase64(12);
$parameters = [
......@@ -166,6 +197,9 @@ protected function install($class_loader, SymfonyStyle $io, $profile, $langcode,
],
],
];
if ($recipe) {
$parameters['parameters']['recipe'] = $recipe;
}
// Create the directory and settings.php if not there so that the installer
// works.
......@@ -277,29 +311,29 @@ protected function selectProfile(SymfonyStyle $io) {
*
* @param string $install_profile
* Install profile to validate.
* @param \Symfony\Component\Console\Style\SymfonyStyle $io
* Symfony style output decorator.
*
* @return bool
* TRUE if the profile is valid, FALSE if not.
*/
protected function validateProfile($install_profile, SymfonyStyle $io) {
protected function validateProfile($install_profile): bool {
// Allow people to install hidden and non-distribution profiles if they
// supply the argument.
$profiles = $this->getProfiles(TRUE, FALSE);
if (!isset($profiles[$install_profile])) {
$error_msg = sprintf("'%s' is not a valid install profile.", $install_profile);
$alternatives = [];
foreach (array_keys($profiles) as $profile_name) {
$lev = levenshtein($install_profile, $profile_name);
if ($lev <= strlen($profile_name) / 4 || str_contains($profile_name, $install_profile)) {
$alternatives[] = $profile_name;
}
}
if (!empty($alternatives)) {
$error_msg .= sprintf(" Did you mean '%s'?", implode("' or '", $alternatives));
}
$io->getErrorStyle()->error($error_msg);
return array_key_exists($install_profile, $this->getProfiles(TRUE, FALSE));
}
/**
* Validates a user provided recipe.
*
* @param string $recipe
* The path to the recipe to validate.
*
* @return bool
* TRUE if the recipe exists, FALSE if not.
*/
protected function validateRecipe(string $recipe): bool {
// It is impossible to validate a recipe fully at this point because that
// requires a container.
if (!is_dir($recipe) || !is_file($recipe . '/recipe.yml')) {
return FALSE;
}
return TRUE;
......
......@@ -28,7 +28,7 @@ class QuickStartCommand extends Command {
protected function configure() {
$this->setName('quick-start')
->setDescription('Installs a Drupal site and runs a web server. This is not meant for production and might be too simple for custom development. It is a quick and easy way to get Drupal running.')
->addArgument('install-profile', InputArgument::OPTIONAL, 'Install profile to install the site in.')
->addArgument('install-profile-or-recipe', InputArgument::OPTIONAL, 'Install profile or recipe directory from which to install the site.')
->addOption('langcode', NULL, InputOption::VALUE_OPTIONAL, 'The language to install the site in. Defaults to en.', 'en')
->addOption('site-name', NULL, InputOption::VALUE_OPTIONAL, 'Set the site name. Defaults to Drupal.', 'Drupal')
->addOption('host', NULL, InputOption::VALUE_OPTIONAL, 'Provide a host for the server to run on. Defaults to 127.0.0.1.', '127.0.0.1')
......@@ -36,7 +36,8 @@ protected function configure() {
->addOption('suppress-login', 's', InputOption::VALUE_NONE, 'Disable opening a login URL in a browser.')
->addUsage('demo_umami --langcode fr')
->addUsage('standard --site-name QuickInstall --host localhost --port 8080')
->addUsage('minimal --host my-site.com --port 80');
->addUsage('minimal --host my-site.com --port 80')
->addUsage('core/recipes/standard --site-name MyDrupalRecipe');
parent::configure();
}
......@@ -49,7 +50,7 @@ protected function execute(InputInterface $input, OutputInterface $output): int
$arguments = [
'command' => 'install',
'install-profile' => $input->getArgument('install-profile'),
'install-profile-or-recipe' => $input->getArgument('install-profile-or-recipe'),
'--langcode' => $input->getOption('langcode'),
'--site-name' => $input->getOption('site-name'),
];
......
<?php
declare(strict_types=1);
namespace Drupal\Core\Config\Action\Attribute;
// cspell:ignore inflector
use Drupal\Core\Config\Action\Exists;
use Drupal\Core\StringTranslation\TranslatableMarkup;
/**
* @internal
* This API is experimental.
*/
#[\Attribute(\Attribute::TARGET_METHOD)]
final class ActionMethod {
/**
* @param \Drupal\Core\Config\Action\Exists $exists
* Determines behavior of action depending on entity existence.
* @param \Drupal\Core\StringTranslation\TranslatableMarkup|string $adminLabel
* The admin label for the user interface.
* @param bool|string $pluralize
* Determines whether to create a pluralized version of the method to enable
* the action to be called multiple times before saving the entity. The
* default behavior is to create an action with a plural form as determined
* by \Symfony\Component\String\Inflector\EnglishInflector::pluralize().
* For example, 'grantPermission' has a pluralized version of
* 'grantPermissions'. If a string is provided this will be the full action
* ID. For example, if the method is called 'addArray' this can be set to
* 'addMultipleArrays'. Set to FALSE if a pluralized version does not make
* logical sense.
*/
public function __construct(
public readonly Exists $exists = Exists::ErrorIfNotExists,
public readonly TranslatableMarkup|string $adminLabel = '',
public readonly bool|string $pluralize = TRUE
) {
}
}
<?php
declare(strict_types=1);
namespace Drupal\Core\Config\Action\Attribute;
use Drupal\Component\Plugin\Attribute\Plugin;
use Drupal\Component\Plugin\Exception\InvalidPluginDefinitionException;
use Drupal\Core\StringTranslation\TranslatableMarkup;
/**
* Defines a ConfigAction attribute object.
*
* Plugin Namespace: Plugin\ConfigAction
*
* @ingroup config_action_api
*
* @internal
* This API is experimental.
*
* @see \Drupal\Core\Config\Action\ConfigActionPluginInterface
* @see \Drupal\Core\Config\Action\ConfigActionManager
* @see plugin_api
*/
#[\Attribute(\Attribute::TARGET_CLASS)]
final class ConfigAction extends Plugin {
/**
* Constructs a ConfigAction attribute.
*
* @param string $id
* The plugin ID.
* @param \Drupal\Core\StringTranslation\TranslatableMarkup|null $admin_label
* The administrative label of the config action. This is optional when
* using a deriver, but in that case the deriver should add an admin label.
* @param string[] $entity_types
* (optional) Allows action shorthand IDs for the listed config entity
* types. If '*' is present in the array then it can apply to all entity
* types. An empty array means that shorthand action IDs are not available
* for this plugin. See ConfigActionManager::convertActionToPluginId().
* @param class-string|null $deriver
* (optional) The deriver class.
*
* @see \Drupal\Core\Config\Action\ConfigActionManager::convertActionToPluginId()
*/
public function __construct(
public readonly string $id,
public readonly ?TranslatableMarkup $admin_label = NULL,
public readonly array $entity_types = [],
public readonly ?string $deriver = NULL,
) {
if ($this->admin_label === NULL && $this->deriver === NULL) {
throw new InvalidPluginDefinitionException($id, sprintf("The '%s' config action plugin must have either an admin label or a deriver", $id));
}
}
}
<?php
declare(strict_types=1);
namespace Drupal\Core\Config\Action;
/**
* @internal
* This API is experimental.
*/
final class ConfigActionException extends \RuntimeException {
}
<?php
declare(strict_types=1);
namespace Drupal\Core\Config\Action;
use Drupal\Component\Plugin\PluginBase;
use Drupal\Core\Cache\CacheBackendInterface;
use Drupal\Core\Config\Action\Attribute\ConfigAction;
use Drupal\Core\Config\ConfigFactoryInterface;
use Drupal\Core\Config\ConfigManagerInterface;
use Drupal\Core\Config\Schema\Mapping;
use Drupal\Core\Config\StorageInterface;
use Drupal\Core\Config\TypedConfigManagerInterface;
use Drupal\Core\Extension\ModuleHandlerInterface;
use Drupal\Core\Plugin\DefaultPluginManager;
use Drupal\Core\Recipe\InvalidConfigException;
use Drupal\Core\Validation\Plugin\Validation\Constraint\FullyValidatableConstraint;
/**
* @defgroup config_action_api Config Action API
* @{
* Information about the classes and interfaces that make up the Config Action
* API.
*
* Configuration actions are plugins that manipulate simple configuration or
* configuration entities. The configuration action plugin manager can apply
* configuration actions. For example, the API is leveraged by recipes to create
* roles if they do not exist already and grant permissions to those roles.
*
* To define a configuration action in a module you need to:
* - Define a Config Action plugin by creating a new class that implements the
* \Drupal\Core\Config\Action\ConfigActionPluginInterface, in namespace
* Plugin\ConfigAction under your module namespace. For more information about
* creating plugins, see the @link plugin_api Plugin API topic. @endlink
* - Config action plugins use the attributes defined by
* \Drupal\Core\Config\Action\Attribute\ConfigAction. See the
* @link attribute Attributes topic @endlink for more information about
* attributes.
*
* Further information and examples:
* - \Drupal\Core\Config\Action\Plugin\ConfigAction\EntityMethod derives
* configuration actions from config entity methods which have the
* \Drupal\Core\Config\Action\Attribute\ActionMethod attribute.
* - \Drupal\Core\Config\Action\Plugin\ConfigAction\EntityCreate allows you to
* create configuration entities if they do not exist.
* - \Drupal\Core\Config\Action\Plugin\ConfigAction\SimpleConfigUpdate allows
* you to update simple configuration using a config action.
* @}
*
* @internal
* This API is experimental.
*/
class ConfigActionManager extends DefaultPluginManager {
/**
* Constructs a new \Drupal\Core\Config\Action\ConfigActionManager object.
*
* @param \Traversable $namespaces
* An object that implements \Traversable which contains the root paths
* keyed by the corresponding namespace to look for plugin implementations.
* @param \Drupal\Core\Cache\CacheBackendInterface $cache_backend
* Cache backend instance to use.
* @param \Drupal\Core\Extension\ModuleHandlerInterface $module_handler
* The module handler to invoke the alter hook with.
* @param \Drupal\Core\Config\ConfigManagerInterface $configManager
* The config manager.
* @param \Drupal\Core\Config\StorageInterface $configStorage
* The active config storage.
* @param \Drupal\Core\Config\TypedConfigManagerInterface $typedConfig
* The typed configuration manager service.
* @param \Drupal\Core\Config\ConfigFactoryInterface $configFactory
* The config factory service.
*/
public function __construct(
\Traversable $namespaces,
CacheBackendInterface $cache_backend,
ModuleHandlerInterface $module_handler,
protected readonly ConfigManagerInterface $configManager,
protected readonly StorageInterface $configStorage,
protected readonly TypedConfigManagerInterface $typedConfig,
protected readonly ConfigFactoryInterface $configFactory,
) {
assert($namespaces instanceof \ArrayAccess, '$namespaces can be accessed like an array');
// Enable this namespace to be searched for plugins.
$namespaces[__NAMESPACE__] = 'core/lib/Drupal/Core/Config/Action';
parent::__construct('Plugin/ConfigAction', $namespaces, $module_handler, ConfigActionPluginInterface::class, ConfigAction::class);
$this->alterInfo('config_action');
$this->setCacheBackend($cache_backend, 'config_action');
}
/**
* Applies a config action.
*
* @param string $action_id
* The ID of the action to apply. This can be a complete configuration
* action plugin ID or a shorthand action ID that is available for the
* entity type of the provided configuration name.
* @param string $configName
* The configuration name. This may be the full name of a config object, or
* it may contain wildcards (to target all config entities of a specific
* type, or a subset thereof). See
* ConfigActionManager::getConfigNamesMatchingExpression() for more detail.
* @param mixed $data
* The data for the action.
*
* @throws \Drupal\Component\Plugin\Exception\PluginException
* Thrown when the config action cannot be found.
* @throws \Drupal\Core\Config\Action\ConfigActionException
* Thrown when the config action fails to apply.
*
* @see \Drupal\Core\Config\Action\ConfigActionManager::getConfigNamesMatchingExpression()
*/
public function applyAction(string $action_id, string $configName, mixed $data): void {
if (!$this->hasDefinition($action_id)) {
// Get the full plugin ID from the shorthand map, if it is available.
$entity_type = $this->configManager->getEntityTypeIdByName($configName);
if ($entity_type) {
$action_id = $this->getShorthandActionIdsForEntityType($entity_type)[$action_id] ?? $action_id;
}
}
/** @var \Drupal\Core\Config\Action\ConfigActionPluginInterface $action */
$action = $this->createInstance($action_id);
foreach ($this->getConfigNamesMatchingExpression($configName) as $name) {
$action->apply($name, $data);
$typed_config = $this->typedConfig->createFromNameAndData($name, $this->configFactory->get($name)->getRawData());
// All config objects are mappings.
assert($typed_config instanceof Mapping);
foreach ($typed_config->getConstraints() as $constraint) {
// Only validate the config if it has explicitly been marked as being
// validatable.
if ($constraint instanceof FullyValidatableConstraint) {
/** @var \Symfony\Component\Validator\ConstraintViolationList $violations */
$violations = $typed_config->validate();
if (count($violations) > 0) {
throw new InvalidConfigException($violations, $typed_config);
}
break;
}
}
}
}
/**
* Gets the names of all active config objects that match an expression.
*
* @param string $expression
* The expression to match. This may be the full name of a config object,
* or it may contain wildcards (to target all config entities of a specific
* type, or a subset thereof). For example:
* - `user.role.*` would target all user roles.
* - `user.role.anonymous` would target only the anonymous user role.
* - `core.entity_view_display.node.*.default` would target the default
* view display of every content type.
* - `core.entity_form_display.*.*.default` would target the default form
* display of every bundle of every entity type.
* The expression MUST begin with the prefix of a config entity type --
* for example, `field.field.` in the case of fields, or `user.role.` for
* user roles. The prefix cannot contain wildcards.
*
* @return string[]
* The names of all active config objects that match the expression.
*
* @throws \Drupal\Core\Config\Action\ConfigActionException
* Thrown if the expression does not match any known config entity type's
* prefix, or if the expression cannot be parsed.
*/
private function getConfigNamesMatchingExpression(string $expression): array {
// If there are no wildcards, we can return the config name as-is.
if (!str_contains($expression, '.*')) {
return [$expression];
}
$entity_type = $this->configManager->getEntityTypeIdByName($expression);
if (empty($entity_type)) {
throw new ConfigActionException("No installed config entity type uses the prefix in the expression '$expression'. Either there is a typo in the expression or this recipe should install an additional module or depend on another recipe.");
}
/** @var \Drupal\Core\Config\Entity\ConfigEntityTypeInterface $entity_type */
$entity_type = $this->configManager->getEntityTypeManager()
->getDefinition($entity_type);
$prefix = $entity_type->getConfigPrefix();
// Convert the expression to a regular expression. We assume that * should
// match the characters allowed by
// \Drupal\Core\Config\ConfigBase::validateName(), which is permissive.
$expression = str_replace('\\*', '[^.:?*<>"\'\/\\\\]+', preg_quote($expression));
$matches = @preg_grep("/^$expression$/", $this->configStorage->listAll("$prefix."));
if ($matches === FALSE) {
throw new ConfigActionException("The expression '$expression' could not be parsed.");
}
return $matches;
}
/**
* Gets a map of shorthand action IDs to plugin IDs for an entity type.
*
* @param string $entityType
* The entity type ID to get the map for.
*
* @return string[]
* An array of plugin IDs keyed by shorthand action ID for the provided
* entity type.
*/
protected function getShorthandActionIdsForEntityType(string $entityType): array {
$map = [];
foreach ($this->getDefinitions() as $plugin_id => $definition) {
if (in_array($entityType, $definition['entity_types'], TRUE) || in_array('*', $definition['entity_types'], TRUE)) {
$regex = '/' . PluginBase::DERIVATIVE_SEPARATOR . '([^' . PluginBase::DERIVATIVE_SEPARATOR . ']*)$/';
$action_id = preg_match($regex, $plugin_id, $matches) ? $matches[1] : $plugin_id;
if (isset($map[$action_id])) {
throw new DuplicateConfigActionIdException(sprintf('The plugins \'%s\' and \'%s\' both resolve to the same shorthand action ID for the \'%s\' entity type', $plugin_id, $map[$action_id], $entityType));
}
$map[$action_id] = $plugin_id;
}
}
return $map;
}
}
<?php
declare(strict_types=1);
namespace Drupal\Core\Config\Action;
/**
* @internal
* This API is experimental.
*/
interface ConfigActionPluginInterface {
/**
* Applies the config action.
*
* @param string $configName
* The name of the config to apply the action to.
* @param mixed $value
* The value for the action to use.
*
* @throws ConfigActionException
*/
public function apply(string $configName, mixed $value): void;
}
<?php
declare(strict_types=1);
namespace Drupal\Core\Config\Action;
/**
* Exception thrown if there are conflicting shorthand action IDs.
*
* @internal
* This API is experimental.
*/
class DuplicateConfigActionIdException extends \RuntimeException {
}
<?php
declare(strict_types=1);
namespace Drupal\Core\Config\Action;
/**
* @internal
* This API is experimental.
*/
final class EntityMethodException extends \RuntimeException {
}
<?php
// phpcs:ignoreFile
declare(strict_types=1);
namespace Drupal\Core\Config\Action;
use Drupal\Core\Config\Entity\ConfigEntityInterface;
/**
* @internal
* This API is experimental.
*/
enum Exists {
case ErrorIfExists;
case ErrorIfNotExists;
case ReturnEarlyIfExists;
case ReturnEarlyIfNotExists;
/**
* Determines if an action should return early depending on $entity.
*
* @param string $configName
* The config name supplied to the action.
* @param \Drupal\Core\Config\Entity\ConfigEntityInterface|null $entity
* The entity, if it exists.
*
* @return bool
* TRUE if the action should return early, FALSE if not.
*
* @throws \Drupal\Core\Config\Action\ConfigActionException
* Thrown depending on $entity and the value of $this.
*/
public function returnEarly(string $configName, ?ConfigEntityInterface $entity): bool {
return match (TRUE) {
$this === self::ReturnEarlyIfExists && $entity !== NULL,
$this === self::ReturnEarlyIfNotExists && $entity === NULL => TRUE,
$this === self::ErrorIfExists && $entity !== NULL => throw new ConfigActionException(sprintf('Entity %s exists', $configName)),
$this === self::ErrorIfNotExists && $entity === NULL => throw new ConfigActionException(sprintf('Entity %s does not exist', $configName)),
default => FALSE
};
}
}
<?php
declare(strict_types=1);
namespace Drupal\Core\Config\Action\Plugin\ConfigAction\Deriver;
use Drupal\Component\Plugin\Derivative\DeriverBase;
use Drupal\Core\Config\Action\Exists;
use Drupal\Core\StringTranslation\StringTranslationTrait;
/**
* @internal
* This API is experimental.
*/
final class EntityCreateDeriver extends DeriverBase {
use StringTranslationTrait;
/**
* {@inheritdoc}
*/
public function getDerivativeDefinitions($base_plugin_definition) {
// These derivatives apply to all entity types.
$base_plugin_definition['entity_types'] = ['*'];
$this->derivatives['ensure_exists'] = $base_plugin_definition + ['constructor_args' => ['exists' => Exists::ReturnEarlyIfExists]];
$this->derivatives['ensure_exists']['admin_label'] = $this->t('Ensure entity exists');
$this->derivatives['create'] = $base_plugin_definition + ['constructor_args' => ['exists' => Exists::ErrorIfExists]];
$this->derivatives['create']['admin_label'] = $this->t('Entity create');
return $this->derivatives;
}
}
<?php
declare(strict_types=1);
namespace Drupal\Core\Config\Action\Plugin\ConfigAction\Deriver;
// cspell:ignore inflector
use Drupal\Component\Plugin\Derivative\DeriverBase;
use Drupal\Component\Plugin\PluginBase;
use Drupal\Core\Config\Action\Attribute\ActionMethod;
use Drupal\Core\Config\Action\EntityMethodException;
use Drupal\Core\Config\Entity\ConfigEntityTypeInterface;
use Drupal\Core\Entity\EntityTypeManagerInterface;
use Drupal\Core\Plugin\Discovery\ContainerDeriverInterface;
use Drupal\Core\StringTranslation\StringTranslationTrait;
use Symfony\Component\DependencyInjection\ContainerInterface;
use Symfony\Component\String\Inflector\EnglishInflector;
use Symfony\Component\String\Inflector\InflectorInterface;
/**
* Derives config action methods from attributed config entity methods.
*
* @internal
* This API is experimental.
*/
final class EntityMethodDeriver extends DeriverBase implements ContainerDeriverInterface {
use StringTranslationTrait;
/**
* Inflector to pluralize words.
*/
protected readonly InflectorInterface $inflector;
/**
* Constructs new EntityMethodDeriver.
*
* @param \Drupal\Core\Entity\EntityTypeManagerInterface $entityTypeManager
* The entity type manager.
*/
public function __construct(protected readonly EntityTypeManagerInterface $entityTypeManager) {
$this->inflector = new EnglishInflector();
}
/**
* {@inheritdoc}
*/
public static function create(ContainerInterface $container, $base_plugin_id) {
return new static(
$container->get('entity_type.manager')
);
}
/**
* {@inheritdoc}
*/
public function getDerivativeDefinitions($base_plugin_definition) {
// Scan all the config entity classes for attributes.
foreach ($this->entityTypeManager->getDefinitions() as $entity_type) {
if ($entity_type instanceof ConfigEntityTypeInterface) {
$reflectionClass = new \ReflectionClass($entity_type->getClass());
while ($reflectionClass) {
foreach ($reflectionClass->getMethods(\ReflectionMethod::IS_PUBLIC) as $method) {
// Only process a method if it is declared on the current class.
// Methods on the parent class will be processed later. This allows
// for a parent to have an attribute and an overriding class does
// not need one. For example,
// \Drupal\layout_builder\Entity\LayoutBuilderEntityViewDisplay::setComponent()
// and \Drupal\Core\Entity\EntityDisplayBase::setComponent().
if ($method->getDeclaringClass()->getName() === $reflectionClass->getName()) {
foreach ($method->getAttributes(ActionMethod::class) as $attribute) {
$this->processMethod($method, $attribute->newInstance(), $entity_type, $base_plugin_definition);
}
}
}
$reflectionClass = $reflectionClass->getParentClass();
}
}
}
return $this->derivatives;
}
/**
* Processes a method to create derivatives.
*
* @param \ReflectionMethod $method
* The entity method.
* @param \Drupal\Core\Config\Action\Attribute\ActionMethod $action_attribute
* The entity method attribute.
* @param \Drupal\Core\Config\Entity\ConfigEntityTypeInterface $entity_type
* The entity type.
* @param array $derivative
* The base plugin definition that will used to create the derivative.
*/
private function processMethod(\ReflectionMethod $method, ActionMethod $action_attribute, ConfigEntityTypeInterface $entity_type, array $derivative): void {
$derivative['admin_label'] = $action_attribute->adminLabel ?: $this->t('@entity_type @method', ['@entity_type' => $entity_type->getLabel(), '@method' => $method->name]);
$derivative['constructor_args'] = [
'method' => $method->name,
'exists' => $action_attribute->exists,
'numberOfParams' => $method->getNumberOfParameters(),
'numberOfRequiredParams' => $method->getNumberOfRequiredParameters(),
'pluralized' => FALSE,
];
$derivative['entity_types'] = [$entity_type->id()];
// Build a config action identifier from the entity type's config
// prefix and the method name. For example, the Role entity adds a
// 'user.role:grantPermission' action.
$this->addDerivative($method->name, $entity_type, $derivative, $method->name);
$pluralized_name = match(TRUE) {
is_string($action_attribute->pluralize) => $action_attribute->pluralize,
$action_attribute->pluralize === FALSE => '',
default => $this->inflector->pluralize($method->name)[0]
};
// Add a pluralized version of the plugin.
if (strlen($pluralized_name) > 0) {
$derivative['constructor_args']['pluralized'] = TRUE;
$derivative['admin_label'] = $this->t('@admin_label (multiple calls)', ['@admin_label' => $derivative['admin_label']]);
$this->addDerivative($pluralized_name, $entity_type, $derivative, $method->name);
}
}
/**
* Adds a derivative.
*
* @param string $action_id
* The action ID.
* @param \Drupal\Core\Config\Entity\ConfigEntityTypeInterface $entity_type
* The entity type.
* @param array $derivative
* The derivative definition.
* @param string $methodName
* The method name.
*/
private function addDerivative(string $action_id, ConfigEntityTypeInterface $entity_type, array $derivative, string $methodName): void {
$id = $entity_type->getConfigPrefix() . PluginBase::DERIVATIVE_SEPARATOR . $action_id;
if (isset($this->derivatives[$id])) {
throw new EntityMethodException(sprintf('Duplicate action can not be created for ID \'%s\' for %s::%s(). The existing action is for the ::%s() method', $id, $entity_type->getClass(), $methodName, $this->derivatives[$id]['constructor_args']['method']));
}
$this->derivatives[$id] = $derivative;
}
}
<?php
declare(strict_types=1);
namespace Drupal\Core\Config\Action\Plugin\ConfigAction\Deriver;
use Drupal\Component\Plugin\Derivative\DeriverBase;
use Drupal\Core\Entity\EntityTypeManagerInterface;
use Drupal\Core\Plugin\Discovery\ContainerDeriverInterface;
use Symfony\Component\DependencyInjection\Container;
use Symfony\Component\DependencyInjection\ContainerInterface;
/**
* @internal
* This API is experimental.
*/
final class PermissionsPerBundleDeriver extends DeriverBase implements ContainerDeriverInterface {
public function __construct(
private readonly EntityTypeManagerInterface $entityTypeManager,
) {}
/**
* {@inheritdoc}
*/
public static function create(ContainerInterface $container, $base_plugin_id) {
return new static(
$container->get(EntityTypeManagerInterface::class),
);
}
/**
* {@inheritdoc}
*/
public function getDerivativeDefinitions($base_plugin_definition) {
foreach ($this->entityTypeManager->getDefinitions() as $id => $entity_type) {
if ($entity_type->getPermissionGranularity() === 'bundle' && ($bundle_entity_type = $entity_type->getBundleEntityType()) !== NULL) {
// Convert unique plugin IDs, like `taxonomy_vocabulary`, into strings
// like `TaxonomyVocabulary`.
$suffix = Container::camelize($bundle_entity_type);
$this->derivatives["grantPermissionsForEach{$suffix}"] = [
'target_entity_type' => $id,
] + $base_plugin_definition;
}
}
return parent::getDerivativeDefinitions($base_plugin_definition);
}
}
<?php
declare(strict_types=1);
namespace Drupal\Core\Config\Action\Plugin\ConfigAction;
use Drupal\Core\Config\Action\Attribute\ConfigAction;
use Drupal\Core\Config\Action\ConfigActionException;
use Drupal\Core\Config\Action\ConfigActionPluginInterface;
use Drupal\Core\Config\Action\Exists;
use Drupal\Core\Config\Action\Plugin\ConfigAction\Deriver\EntityCreateDeriver;
use Drupal\Core\Config\ConfigManagerInterface;
use Drupal\Core\Plugin\ContainerFactoryPluginInterface;
use Symfony\Component\DependencyInjection\ContainerInterface;
/**
* @internal
* This API is experimental.
*/
#[ConfigAction(
id: 'entity_create',
deriver: EntityCreateDeriver::class,
)]
final class EntityCreate implements ConfigActionPluginInterface, ContainerFactoryPluginInterface {
/**
* Constructs a EntityCreate object.
*
* @param \Drupal\Core\Config\ConfigManagerInterface $configManager
* The config manager.
* @param \Drupal\Core\Config\Action\Exists $exists
* Determines behavior of action depending on entity existence.
*/
public function __construct(
protected readonly ConfigManagerInterface $configManager,
protected readonly Exists $exists
) {
}
/**
* {@inheritdoc}
*/
public static function create(ContainerInterface $container, array $configuration, $plugin_id, $plugin_definition): static {
assert(is_array($plugin_definition) && is_array($plugin_definition['constructor_args']), '$plugin_definition contains the expected settings');
return new static($container->get('config.manager'), ...$plugin_definition['constructor_args']);
}
/**
* {@inheritdoc}
*/
public function apply(string $configName, mixed $value): void {
if (!is_array($value)) {
throw new ConfigActionException(sprintf("The value provided to create %s must be an array", $configName));
}
/** @var \Drupal\Core\Config\Entity\ConfigEntityInterface|null $entity */
$entity = $this->configManager->loadConfigEntityByName($configName);
if ($this->exists->returnEarly($configName, $entity)) {
return;
}
$entity_type_manager = $this->configManager->getEntityTypeManager();
$entity_type_id = $this->configManager->getEntityTypeIdByName($configName);
if ($entity_type_id === NULL) {
throw new ConfigActionException(sprintf("Cannot determine a config entity type from %s", $configName));
}
/** @var \Drupal\Core\Config\Entity\ConfigEntityTypeInterface $entity_type */
$entity_type = $entity_type_manager->getDefinition($entity_type_id);
$id = substr($configName, strlen($entity_type->getConfigPrefix()) + 1);
$entity_type_manager
->getStorage($entity_type->id())
->create($value + ['id' => $id])
->save();
}
}
<?php
declare(strict_types=1);
namespace Drupal\Core\Config\Action\Plugin\ConfigAction;
use Drupal\Core\Config\Action\Attribute\ConfigAction;
use Drupal\Core\Config\Action\ConfigActionPluginInterface;
use Drupal\Core\Config\Action\EntityMethodException;
use Drupal\Core\Config\Action\Exists;
use Drupal\Core\Config\Action\Plugin\ConfigAction\Deriver\EntityMethodDeriver;
use Drupal\Core\Config\ConfigManagerInterface;
use Drupal\Core\Config\Entity\ConfigEntityInterface;
use Drupal\Core\Plugin\ContainerFactoryPluginInterface;
use Symfony\Component\DependencyInjection\ContainerInterface;
/**
* Makes config entity methods with the ActionMethod attribute into actions.
*
* For example, adding the ActionMethod attribute to
* \Drupal\user\Entity\Role::grantPermission() allows permissions to be added to
* roles via config actions.
*
* When calling \Drupal\Core\Config\Action\ConfigActionManager::applyAction()
* the $data parameter is mapped to the method's arguments using the following
* rules:
* - If $data is not an array, the method must only have one argument or one
* required argument.
* - If $data is an array and the method only accepts a single argument, the
* array will be passed to the first argument.
* - If $data is an array and the method accepts more than one argument, $data
* will be unpacked into the method arguments.
*
* @internal
* This API is experimental.
*
* @see \Drupal\Core\Config\Action\Attribute\ActionMethod
*/
#[ConfigAction(
id: 'entity_method',
deriver: EntityMethodDeriver::class,
)]
final class EntityMethod implements ConfigActionPluginInterface, ContainerFactoryPluginInterface {
/**
* Constructs a EntityMethod object.
*
* @param string $pluginId
* The config action plugin ID.
* @param \Drupal\Core\Config\ConfigManagerInterface $configManager
* The config manager.
* @param string $method
* The method to call on the config entity.
* @param \Drupal\Core\Config\Action\Exists $exists
* Determines behavior of action depending on entity existence.
* @param int $numberOfParams
* The number of parameters the method has.
* @param int $numberOfRequiredParams
* The number of required parameters the method has.
* @param bool $pluralized
* Determines whether an array maps to multiple calls.
*/
public function __construct(
protected readonly string $pluginId,
protected readonly ConfigManagerInterface $configManager,
protected readonly string $method,
protected readonly Exists $exists,
protected readonly int $numberOfParams,
protected readonly int $numberOfRequiredParams,
protected readonly bool $pluralized
) {
}
/**
* {@inheritdoc}
*/
public static function create(ContainerInterface $container, array $configuration, $plugin_id, $plugin_definition): static {
assert(is_array($plugin_definition) && is_array($plugin_definition['constructor_args']), '$plugin_definition contains the expected settings');
return new static(
$plugin_id,
$container->get('config.manager'),
...$plugin_definition['constructor_args']
);
}
/**
* {@inheritdoc}
*/
public function apply(string $configName, mixed $value): void {
/** @var \Drupal\Core\Config\Entity\ConfigEntityInterface|null $entity */
$entity = $this->configManager->loadConfigEntityByName($configName);
if ($this->exists->returnEarly($configName, $entity)) {
return;
}
$entity = $this->pluralized ? $this->applyPluralized($entity, $value) : $this->applySingle($entity, $value);
$entity->save();
}
/**
* Applies the action to entity treating the $values array as multiple calls.
*
* @param \Drupal\Core\Config\Entity\ConfigEntityInterface $entity
* The entity to apply the action to.
* @param mixed $values
* The values for the action to use.
*
* @return \Drupal\Core\Config\Entity\ConfigEntityInterface
* The unsaved entity with the action applied.
*/
private function applyPluralized(ConfigEntityInterface $entity, mixed $values): ConfigEntityInterface {
if (!is_array($values)) {
throw new EntityMethodException(sprintf('The pluralized entity method config action \'%s\' requires an array value in order to call %s::%s() multiple times', $this->pluginId, $entity->getEntityType()->getClass(), $this->method));
}
foreach ($values as $value) {
$entity = $this->applySingle($entity, $value);
}
return $entity;
}
/**
* Applies the action to entity treating the $values array a single call.
*
* @param \Drupal\Core\Config\Entity\ConfigEntityInterface $entity
* The entity to apply the action to.
* @param mixed $value
* The value for the action to use.
*
* @return \Drupal\Core\Config\Entity\ConfigEntityInterface
* The unsaved entity with the action applied.
*/
private function applySingle(ConfigEntityInterface $entity, mixed $value): ConfigEntityInterface {
// If $value is not an array then we only support calling the method if the
// number of parameters or required parameters is 1. If there is only 1
// parameter and $value is an array then assume that the parameter expects
// an array.
if (!is_array($value) || $this->numberOfParams === 1) {
if ($this->numberOfRequiredParams !== 1 && $this->numberOfParams !== 1) {
throw new EntityMethodException(sprintf('Entity method config action \'%s\' requires an array value. The number of parameters or required parameters for %s::%s() is not 1', $this->pluginId, $entity->getEntityType()->getClass(), $this->method));
}
$entity->{$this->method}($value);
}
else {
$entity->{$this->method}(...$value);
}
return $entity;
}
}
<?php
declare(strict_types=1);
namespace Drupal\Core\Config\Action\Plugin\ConfigAction;
use Drupal\Component\Assertion\Inspector;
use Drupal\Core\Config\Action\Attribute\ConfigAction;
use Drupal\Core\Config\Action\ConfigActionException;
use Drupal\Core\Config\Action\ConfigActionPluginInterface;
use Drupal\Core\Config\Action\Plugin\ConfigAction\Deriver\PermissionsPerBundleDeriver;
use Drupal\Core\Config\ConfigManagerInterface;
use Drupal\Core\Entity\EntityTypeBundleInfoInterface;
use Drupal\Core\Plugin\ContainerFactoryPluginInterface;
use Drupal\user\RoleInterface;
use Symfony\Component\DependencyInjection\ContainerInterface;
/**
* @internal
* This API is experimental.
*/
#[ConfigAction(
id: 'permissions_per_bundle',
entity_types: ['user_role'],
deriver: PermissionsPerBundleDeriver::class,
)]
final class PermissionsPerBundle implements ConfigActionPluginInterface, ContainerFactoryPluginInterface {
public function __construct(
private readonly ConfigManagerInterface $configManager,
private readonly EntityTypeBundleInfoInterface $entityTypeBundleInfo,
private readonly string $targetEntityType,
) {}
/**
* {@inheritdoc}
*/
public static function create(ContainerInterface $container, array $configuration, $plugin_id, $plugin_definition) {
assert(is_array($plugin_definition));
$target_entity_type = $plugin_definition['target_entity_type'];
return new static(
$container->get(ConfigManagerInterface::class),
$container->get(EntityTypeBundleInfoInterface::class),
$target_entity_type,
);
}
/**
* {@inheritdoc}
*/
public function apply(string $configName, mixed $value): void {
$role = $this->configManager->loadConfigEntityByName($configName);
if (!($role instanceof RoleInterface)) {
throw new ConfigActionException(sprintf("Cannot determine role from %s", $configName));
}
assert(is_string($value) || is_array($value));
[$permissions, $except_bundles] = self::parseValue($value);
if (empty($permissions) || !Inspector::assertAllMatch('%bundle', $permissions, TRUE)) {
throw new ConfigActionException(sprintf("The permissions provided %s must be an array of strings that contain '%%bundle'.", var_export($value, TRUE)));
}
$bundles = $this->entityTypeBundleInfo->getBundleInfo($this->targetEntityType);
foreach (array_keys($bundles) as $bundle_id) {
if (in_array($bundle_id, $except_bundles, TRUE)) {
continue;
}
/** @var string[] $actual_permissions */
$actual_permissions = str_replace('%bundle', $bundle_id, $permissions);
array_walk($actual_permissions, $role->grantPermission(...));
}
$role->save();
}
/**
* Parses the value supplied to ::apply().
*
* @param string|array<string|string[]> $value
* One of:
* - A single string (a permission template).
* - An array of strings (several permission templates).
* - An array with a `permissions` element, and an optional `except`
* element, either of which can be an array or a string. `except` accepts
* a single bundle, or a list of bundles, to exclude from the permissions
* being granted.
*
* @return array<int, array<int<0, max>, array<string>|string>>
* An indexed array with two elements: the array of permissions to grant,
* and the list of bundles to ignore.
*/
private static function parseValue(string|array $value): array {
if (is_string($value)) {
return [[$value], []];
}
if (array_is_list($value)) {
return [$value, []];
}
$permissions = $value['permissions'] ?? [];
$except_bundles = $value['except'] ?? [];
return [(array) $permissions, (array) $except_bundles];
}
}
<?php
declare(strict_types=1);
namespace Drupal\Core\Config\Action\Plugin\ConfigAction;
use Drupal\Core\Config\Action\Attribute\ConfigAction;
use Drupal\Core\Config\Action\ConfigActionException;
use Drupal\Core\Config\Action\ConfigActionPluginInterface;
use Drupal\Core\Config\ConfigFactoryInterface;
use Drupal\Core\Plugin\ContainerFactoryPluginInterface;
use Drupal\Core\StringTranslation\TranslatableMarkup;
use Symfony\Component\DependencyInjection\ContainerInterface;
/**
* @internal
* This API is experimental.
*/
#[ConfigAction(
id: 'simple_config_update',
admin_label: new TranslatableMarkup('Simple configuration update'),
)]
final class SimpleConfigUpdate implements ConfigActionPluginInterface, ContainerFactoryPluginInterface {
/**
* Constructs a SimpleConfigUpdate object.
*
* @param \Drupal\Core\Config\ConfigFactoryInterface $configFactory
* The config factory.
*/
public function __construct(
protected readonly ConfigFactoryInterface $configFactory,
) {
}
/**
* {@inheritdoc}
*/
public static function create(ContainerInterface $container, array $configuration, $plugin_id, $plugin_definition): static {
return new static($container->get('config.factory'));
}
/**
* {@inheritdoc}
*/
public function apply(string $configName, mixed $value): void {
$config = $this->configFactory->getEditable($configName);
// @todo https://www.drupal.org/i/3439713 Should we error if this is a
// config entity?
if ($config->isNew()) {
throw new ConfigActionException(sprintf('Config %s does not exist so can not be updated', $configName));
}
// Expect $value to be an array whose keys are the config keys to update.
if (!is_array($value)) {
throw new ConfigActionException(sprintf('Config %s can not be updated because $value is not an array', $configName));
}
foreach ($value as $key => $value) {
$config->set($key, $value);
}
$config->save();
}
}
<?php
declare(strict_types=1);
namespace Drupal\Core\Config\Checkpoint;
/**
* A value object to store information about a checkpoint.
*
* @internal
* This API is experimental.
*/
final class Checkpoint {
/**
* Constructs a checkpoint object.
*
* @param string $id
* The checkpoint's ID.
* @param \Stringable|string $label
* The human-readable label.
* @param int $timestamp
* The timestamp when the checkpoint was created.
* @param string|null $parent
* The ID of the checkpoint's parent.
*/
public function __construct(
public readonly string $id,
public readonly \Stringable|string $label,
public readonly int $timestamp,
public readonly ?string $parent,
) {
}
}
0% Loading or .
You are about to add 0 people to the discussion. Proceed with caution.
Finish editing this message first!
Please register or to comment