diff --git a/.travis.yml b/.travis.yml index 498f2dc8..80eb8f54 100644 --- a/.travis.yml +++ b/.travis.yml @@ -14,6 +14,7 @@ php: env: - DRUPAL_CORE=8.0.x - DRUPAL_CORE=8.1.x + - DRUPAL_CORE=8.2.x matrix: allow_failures: @@ -59,7 +60,9 @@ before_script: # Run composer install for Drupal 8.1. We need an up-to-date composer when # installing Drupal 8.1. - - if [ "$DRUPAL_CORE" = "8.1.x" ]; then composer self-update && composer install; fi + # Disabled for now because core still includes the vendor directory, see + # https://www.drupal.org/node/1475510 + # - if [ "$DRUPAL_CORE" = "8.1.x" ]; then composer self-update && composer install; fi # Start a web server on port 8888, run in the background. - php -S localhost:8888 & diff --git a/config/schema/rules.action.schema.yml b/config/schema/rules.action.schema.yml index ac585450..bbefa6aa 100644 --- a/config/schema/rules.action.schema.yml +++ b/config/schema/rules.action.schema.yml @@ -1,3 +1,10 @@ +# Per default the schema of arbitrary context values of an action cannot be +# typed. Actions that need translatability or other features of the config +# system must specify their context value schema explicitly, see examples below. +rules.action.context_values.*: + type: ignore + label: Context values + rules.action.context_values.rules_system_message: type: mapping label: Message action context values diff --git a/config/schema/rules.context.schema.yml b/config/schema/rules.context.schema.yml index 45ee7071..3f6126e0 100644 --- a/config/schema/rules.context.schema.yml +++ b/config/schema/rules.context.schema.yml @@ -25,12 +25,6 @@ rules.context.values: # The entries depend on the plugin here. Plugins have to extend this and # provide a suiting schema. -rules.context.mapping: - type: sequence - label: 'Context mapping' - sequence: - - type: string - rules.context.processors: type: sequence label: 'Context processors' diff --git a/config/schema/rules.data_types.schema.yml b/config/schema/rules.data_types.schema.yml index c40fee14..bbc9c351 100644 --- a/config/schema/rules.data_types.schema.yml +++ b/config/schema/rules.data_types.schema.yml @@ -7,11 +7,11 @@ rules_component: label: 'Context definitions' sequence: - type: rules.context.definition - provided_context: + provided_context_definitions: type: sequence - label: 'Names of provided context' + label: 'Provided context definitions' sequence: - - type: string + - type: rules.context.definition expression: type: rules_expression.[id] label: 'Expression configuration' diff --git a/config/schema/rules.schema.yml b/config/schema/rules.schema.yml index 7b1b7225..9230169d 100644 --- a/config/schema/rules.schema.yml +++ b/config/schema/rules.schema.yml @@ -35,9 +35,21 @@ rules.reaction.*: label: type: label label: 'Label' - event: - type: string - label: 'Event' + events: + type: sequence + label: 'Events' + sequence: + type: mapping + label: 'Event' + mapping: + event_name: + type: string + label: 'Name' + configuration: + type: sequence + label: 'Configuration' + sequence: + type: mapping module: type: string label: 'Module' diff --git a/src/Context/ContextAwarePluginInterface.php b/src/Context/ContextAwarePluginInterface.php index dd5c37a3..97806c18 100644 --- a/src/Context/ContextAwarePluginInterface.php +++ b/src/Context/ContextAwarePluginInterface.php @@ -20,7 +20,46 @@ interface ContextAwarePluginInterface extends CoreContextAwarePluginInterface { * When a plugin is configured half-way or even fully, some context values are * already available upon which the definition of subsequent or provided * context can be refined. + * + * Implement this method, when the plugin's context definitions need to be + * refined. When the selected data definitions should be refined, implement + * ::assertMetadata() instead. + * + * Note that context gets refined at configuration and execution time of the + * plugin. + * + * @param \Drupal\Core\TypedData\DataDefinitionInterface[] $selected_data + * An array of data definitions for context that is mapped using a data + * selector, keyed by context name. + */ + public function refineContextDefinitions(array $selected_data); + + /** + * Asserts additional metadata for the selected data. + * + * Allows the plugin to assert additional metadata that is in place when the + * plugin has been successfully executed. A typical use-case would be + * asserting the node type for a "Node is of type" condition. By doing so, + * sub-sequent executed plugins are aware of the metadata and can build upon + * it. + * + * Implement this method, when the selected data definitions need to be + * refined. When the plugin's context definitions should be refined, implement + * ::refineContextDefinitions() instead. + * + * Note that metadata is only asserted on configuration time. The plugin has + * to ensure that the run-time data matches the asserted configuration if it + * has been executed successfully. + * + * @param \Drupal\Core\TypedData\DataDefinitionInterface[] $selected_data + * An array of data definitions for context that is mapped using a data + * selector, keyed by context name. + * + * @return \Drupal\Core\TypedData\DataDefinitionInterface[] + * An array of modified data definitions, keyed as the passed array. Note + * data definitions need to be cloned *before* they are modified, such that + * the changes do not propagate unintentionally. */ - public function refineContextDefinitions(); + public function assertMetadata(array $selected_data); } diff --git a/src/Engine/IntegrityCheckTrait.php b/src/Context/ContextHandlerIntegrityTrait.php similarity index 63% rename from src/Engine/IntegrityCheckTrait.php rename to src/Context/ContextHandlerIntegrityTrait.php index 19b473b6..0f5135d6 100644 --- a/src/Engine/IntegrityCheckTrait.php +++ b/src/Context/ContextHandlerIntegrityTrait.php @@ -2,25 +2,26 @@ /** * @file - * Contains \Drupal\rules\Engine\IntegrityCheckTrait. + * Contains \Drupal\rules\Engine\ContextHandlerIntegrityTrait. */ -namespace Drupal\rules\Engine; +namespace Drupal\rules\Context; -use Drupal\Core\Plugin\Context\ContextDefinitionInterface; +use Drupal\Core\Plugin\Context\ContextDefinitionInterface as CoreContextDefinitionInterface; use Drupal\Core\Plugin\ContextAwarePluginInterface as CoreContextAwarePluginInterface; -use Drupal\Core\TypedData\ComplexDataInterface; use Drupal\Core\TypedData\DataDefinitionInterface; -use Drupal\Core\TypedData\ListInterface; -use Drupal\Core\TypedData\PrimitiveInterface; use Drupal\rules\Context\ContextDefinitionInterface as RulesContextDefinitionInterface; -use Drupal\rules\Context\ContextProviderInterface; +use Drupal\rules\Engine\ExecutionMetadataStateInterface; +use Drupal\rules\Engine\IntegrityViolation; +use Drupal\rules\Engine\IntegrityViolationList; use Drupal\rules\Exception\RulesIntegrityException; /** - * Provides shared integrity checking methods for conditions and actions. + * Extends the context handler trait with support for checking integrity. */ -trait IntegrityCheckTrait { +trait ContextHandlerIntegrityTrait { + + use ContextHandlerTrait; /** * Performs the integrity check. @@ -34,16 +35,19 @@ trait IntegrityCheckTrait { * @return \Drupal\rules\Engine\IntegrityViolationList * The list of integrity violations. */ - protected function doCheckIntegrity(CoreContextAwarePluginInterface $plugin, ExecutionMetadataStateInterface $metadata_state) { + protected function checkContextConfigIntegrity(CoreContextAwarePluginInterface $plugin, ExecutionMetadataStateInterface $metadata_state) { $violation_list = new IntegrityViolationList(); $context_definitions = $plugin->getContextDefinitions(); + // Make sure that all provided variables by this plugin are added to the + // execution metadata state. + $this->addProvidedContextDefinitions($plugin, $metadata_state); + foreach ($context_definitions as $name => $context_definition) { // Check if a data selector is configured that maps to the state. if (isset($this->configuration['context_mapping'][$name])) { try { - $data_definition = $metadata_state->fetchDefinitionByPropertyPath($this->configuration['context_mapping'][$name]); - + $data_definition = $this->getMappedDefinition($name, $metadata_state); $this->checkDataTypeCompatible($context_definition, $data_definition, $name, $violation_list); } catch (RulesIntegrityException $e) { @@ -83,7 +87,7 @@ protected function doCheckIntegrity(CoreContextAwarePluginInterface $plugin, Exe $violation_list->add($violation); } } - elseif ($context_definition->isRequired()) { + elseif ($context_definition->isRequired() && $context_definition->getDefaultValue() === NULL) { $violation = new IntegrityViolation(); $violation->setMessage($this->t('The required context %context_name is missing.', [ '%context_name' => $context_definition->getLabel(), @@ -112,10 +116,6 @@ protected function doCheckIntegrity(CoreContextAwarePluginInterface $plugin, Exe } } - // Make sure that all provided variables by this plugin are added to the - // execution metadata state. - $this->addProvidedVariablesToExecutionMetadataState($plugin, $metadata_state); - return $violation_list; } @@ -131,28 +131,17 @@ protected function doCheckIntegrity(CoreContextAwarePluginInterface $plugin, Exe * @param \Drupal\rules\Engine\IntegrityViolationList $violation_list * The list of violations where new ones will be added. */ - protected function checkDataTypeCompatible(ContextDefinitionInterface $context_definition, DataDefinitionInterface $provided, $context_name, IntegrityViolationList $violation_list) { - $expected_class = $context_definition->getDataDefinition()->getClass(); - $provided_class = $provided->getClass(); - $expected_type_problem = NULL; - - if (is_subclass_of($expected_class, PrimitiveInterface::class) - && !is_subclass_of($provided_class, PrimitiveInterface::class) - ) { - $expected_type_problem = $this->t('primitive'); - } - elseif (is_subclass_of($expected_class, ListInterface::class) - && !is_subclass_of($provided_class, ListInterface::class) - ) { - $expected_type_problem = $this->t('list'); - } - elseif (is_subclass_of($expected_class, ComplexDataInterface::class) - && !is_subclass_of($provided_class, ComplexDataInterface::class) - ) { - $expected_type_problem = $this->t('complex'); + protected function checkDataTypeCompatible(CoreContextDefinitionInterface $context_definition, DataDefinitionInterface $provided, $context_name, IntegrityViolationList $violation_list) { + // Compare data types. For now, fail if they are not equal. + // @todo: Add support for matching based upon type-inheritance. + $target_type = $context_definition->getDataDefinition()->getDataType(); + + // Special case any and entity target types for now. + if ($target_type == 'any' || ($target_type == 'entity' && strpos($provided->getDataType(), 'entity:') !== FALSE)) { + return; } - - if ($expected_type_problem) { + if ($target_type != $provided->getDataType()) { + $expected_type_problem = $context_definition->getDataDefinition()->getDataType(); $violation = new IntegrityViolation(); $violation->setMessage($this->t('Expected a @expected_type data type for context %context_name but got a @provided_type data type instead.', [ '@expected_type' => $expected_type_problem, @@ -165,33 +154,4 @@ protected function checkDataTypeCompatible(ContextDefinitionInterface $context_d } } - /** - * Adds provided variables to the execution metadata state. - * - * @param CoreContextAwarePluginInterface $plugin - * The action or condition plugin that may provide variables. - * @param \Drupal\rules\Engine\ExecutionMetadataStateInterface $metadata_state - * The excution metadata state to add variables to. - */ - public function addProvidedVariablesToExecutionMetadataState(CoreContextAwarePluginInterface $plugin, ExecutionMetadataStateInterface $metadata_state) { - if ($plugin instanceof ContextProviderInterface) { - $provided_context_definitions = $plugin->getProvidedContextDefinitions(); - - foreach ($provided_context_definitions as $name => $context_definition) { - if (isset($this->configuration['provides_mapping'][$name])) { - // Populate the state with the new variable that is provided by this - // plugin. That is necessary so that the integrity check in subsequent - // actions knows about the variable and does not throw violations. - $metadata_state->setDataDefinition( - $this->configuration['provides_mapping'][$name], - $context_definition->getDataDefinition() - ); - } - else { - $metadata_state->setDataDefinition($name, $context_definition->getDataDefinition()); - } - } - } - } - } diff --git a/src/Context/ContextHandlerTrait.php b/src/Context/ContextHandlerTrait.php index d9d77664..462e27e2 100644 --- a/src/Context/ContextHandlerTrait.php +++ b/src/Context/ContextHandlerTrait.php @@ -7,10 +7,12 @@ namespace Drupal\rules\Context; -use Drupal\Core\Plugin\Context\Context; +use Drupal\Component\Plugin\Exception\ContextException; use Drupal\Core\Plugin\ContextAwarePluginInterface as CoreContextAwarePluginInterface; +use Drupal\rules\Engine\ExecutionMetadataStateInterface; use Drupal\rules\Engine\ExecutionStateInterface; use Drupal\rules\Exception\RulesEvaluationException; +use Drupal\rules\Exception\RulesIntegrityException; /** * Provides methods for handling context based on the plugin configuration. @@ -30,61 +32,188 @@ trait ContextHandlerTrait { protected $processorManager; /** - * Maps variables from rules state into the plugin context. + * Prepares plugin context based upon the set context configuration. + * + * The plugin is prepared for execution by mapping the variables from the + * execution state into the plugin context and applying data processors. + * In addition, it is ensured that all required context is basically + * available as defined. This include the following checks: + * - Required context must have a value set. + * - Context may not have NULL values unless the plugin allows it. * * @param \Drupal\Core\Plugin\ContextAwarePluginInterface $plugin * The plugin that is populated with context values. * @param \Drupal\rules\Engine\ExecutionStateInterface $state - * The Rules state containing available variables. + * The execution state containing available variables. * * @throws \Drupal\rules\Exception\RulesEvaluationException - * In case a required context is missing for the plugin. + * Thrown if some context is not satisfied; e.g. a required context is + * missing. + * + * @see ::prepareContextWithMetadata() */ - protected function mapContext(CoreContextAwarePluginInterface $plugin, ExecutionStateInterface $state) { - $context_definitions = $plugin->getContextDefinitions(); - foreach ($context_definitions as $name => $definition) { - // Check if a data selector is configured that maps to the state. - if (isset($this->configuration['context_mapping'][$name])) { - $typed_data = $state->fetchDataByPropertyPath($this->configuration['context_mapping'][$name]); - - if ($typed_data->getValue() === NULL && !$definition->isAllowedNull()) { - throw new RulesEvaluationException('The value of data selector ' - . $this->configuration['context_mapping'][$name] . " is NULL, but the context $name in " - . $plugin->getPluginId() . ' requires a value.'); + protected function prepareContext(CoreContextAwarePluginInterface $plugin, ExecutionStateInterface $state) { + if (isset($this->configuration['context_values'])) { + foreach ($this->configuration['context_values'] as $name => $value) { + $plugin->setContextValue($name, $value); + } + } + + $selected_data = []; + // Map context by applying data selectors and collected the definitions of + // selected data for refining context definitions later. Note, that we must + // refine context definitions on execution time also, such that provided + // context gets the right metadata attached. + if (isset($this->configuration['context_mapping'])) { + foreach ($this->configuration['context_mapping'] as $name => $selector) { + $typed_data = $state->fetchDataByPropertyPath($selector); + $plugin->setContextValue($name, $typed_data); + $selected_data[$name] = $typed_data->getDataDefinition(); + } + } + + if ($plugin instanceof ContextAwarePluginInterface) { + // Getting context values may lead to undocumented exceptions if context + // is not set right now. So catch those exceptions. + // @todo: Remove ones https://www.drupal.org/node/2677162 got fixed. + try { + $plugin->refineContextDefinitions($selected_data); + } + catch (ContextException $e) { + if (strpos($e->getMessage(), 'context is required') === FALSE) { + throw new RulesEvaluationException($e->getMessage()); } - $context = $plugin->getContext($name); - $new_context = Context::createFromContext($context, $typed_data); - $plugin->setContext($name, $new_context); } - elseif (isset($this->configuration['context_values']) - && array_key_exists($name, $this->configuration['context_values']) - ) { + } - if ($this->configuration['context_values'][$name] === NULL && !$definition->isAllowedNull()) { - throw new RulesEvaluationException("The context value for $name is NULL, but the context $name in " + // Apply data processors. + $this->processData($plugin, $state); + + // Finally, ensure all contexts are set as expected now. + foreach ($plugin->getContextDefinitions() as $name => $definition) { + if ($plugin->getContextValue($name) === NULL && $definition->isRequired()) { + // If a context mapping has been specified, the value might end up NULL + // but valid (e.g. a reference on an empty property). In that case + // isAllowedNull determines whether the context is conform. + if (!isset($this->configuration['context_mapping'][$name])) { + throw new RulesEvaluationException("Required context $name is missing for plugin " + . $plugin->getPluginId() . '.'); + } + elseif (!$definition->isAllowedNull()) { + throw new RulesEvaluationException("The context for $name is NULL, but the context $name in " . $plugin->getPluginId() . ' requires a value.'); } + } + } + } + + /** + * Prepares plugin context based upon the set context configuration. + * + * The configuration is applied as far as possible without having execution + * time data. That means, the configured context values are set and context is + * refined while leveraging the definitions of selected data. + * + * @param \Drupal\Core\Plugin\ContextAwarePluginInterface $plugin + * The plugin that is prepared. + * @param \Drupal\rules\Engine\ExecutionMetadataStateInterface $metadata_state + * The metadata state, prepared for the current expression. + * + * @throws \Drupal\Component\Plugin\Exception\ContextException + * Thrown if the plugin tries to access some not-defined context. As this is + * a developer error, this should not be caught. + * + * @see ::prepareContext() + */ + protected function prepareContextWithMetadata(CoreContextAwarePluginInterface $plugin, ExecutionMetadataStateInterface $metadata_state) { + if (isset($this->configuration['context_values'])) { + foreach ($this->configuration['context_values'] as $name => $value) { + $plugin->setContextValue($name, $value); + } + } - $context = $plugin->getContext($name); - $new_context = Context::createFromContext($context, $this->configuration['context_values'][$name]); - $plugin->setContext($name, $new_context); + if ($plugin instanceof ContextAwarePluginInterface) { + $selected_data = $this->getSelectedData($metadata_state); + // Getting context values may lead to undocumented exceptions if context + // is not set right now. So catch those exceptions. + // @todo: Remove ones https://www.drupal.org/node/2677162 got fixed. + try { + $plugin->refineContextDefinitions($selected_data); } - elseif ($definition->isRequired()) { - throw new RulesEvaluationException("Required context $name is missing for plugin " - . $plugin->getPluginId() . '.'); + catch (ContextException $e) { + if (strpos($e->getMessage(), 'context is required') === FALSE) { + throw $e; + } } } } /** - * Maps provided context values from the plugin to the Rules state. + * Gets definitions of all selected data at configuration time. + * + * @param \Drupal\rules\Engine\ExecutionMetadataStateInterface $metadata_state + * The metadata state. * - * @param ContextProviderInterface $plugin - * The plugin where the context values are extracted. + * @return \Drupal\Core\TypedData\DataDefinitionInterface[] + * An array of data definitions for context that is mapped using a data + * selector, keyed by context name. + */ + protected function getSelectedData(ExecutionMetadataStateInterface $metadata_state) { + $selected_data = []; + // Collected the definitions of selected data for refining context + // definitions. + if (isset($this->configuration['context_mapping'])) { + // If no state is available, we need to fetch at least the definitions of + // selected data for refining context. + foreach ($this->configuration['context_mapping'] as $name => $selector) { + try { + $selected_data[$name] = $this->getMappedDefinition($name, $metadata_state); + } + catch (RulesIntegrityException $e) { + // Ignore invalid data selectors here, such that context gets refined + // as far as possible still and can be respected by the UI when fixing + // broken selectors. + } + } + } + return $selected_data; + } + + /** + * Gets the definition of the data that is mapped to the given context. + * + * @param string $context_name + * The name of the context. + * @param \Drupal\rules\Engine\ExecutionMetadataStateInterface $metadata_state + * The metadata state containing metadata about available variables. + * + * @return \Drupal\Core\TypedData\DataDefinitionInterface|null + * A data definition if the property path could be applied, or NULL if the + * context is not mapped. + * + * @throws \Drupal\rules\Exception\RulesIntegrityException + * Thrown if the data selector that is configured for the context is + * invalid. + */ + protected function getMappedDefinition($context_name, ExecutionMetadataStateInterface $metadata_state) { + if (isset($this->configuration['context_mapping'][$context_name])) { + return $metadata_state->fetchDefinitionByPropertyPath($this->configuration['context_mapping'][$context_name]); + } + } + + /** + * Adds provided context values from the plugin to the execution state. + * + * @param CoreContextAwarePluginInterface $plugin + * The context aware plugin of which to add provided context. * @param \Drupal\rules\Engine\ExecutionStateInterface $state * The Rules state where the context variables are added. */ - protected function mapProvidedContext(ContextProviderInterface $plugin, ExecutionStateInterface $state) { + protected function addProvidedContext(CoreContextAwarePluginInterface $plugin, ExecutionStateInterface $state) { + // If the plugin does not support providing context, there is nothing to do. + if (!$plugin instanceof ContextProviderInterface) { + return; + } $provides = $plugin->getProvidedContextDefinitions(); foreach ($provides as $name => $provided_definition) { // Avoid name collisions in the rules state: provided variables can be @@ -98,6 +227,61 @@ protected function mapProvidedContext(ContextProviderInterface $plugin, Executio } } + /** + * Adds the definitions of provided context to the execution metadata state. + * + * @param CoreContextAwarePluginInterface $plugin + * The context aware plugin of which to add provided context. + * @param \Drupal\rules\Engine\ExecutionMetadataStateInterface $metadata_state + * The execution metadata state to add variables to. + */ + protected function addProvidedContextDefinitions(CoreContextAwarePluginInterface $plugin, ExecutionMetadataStateInterface $metadata_state) { + // If the plugin does not support providing context, there is nothing to do. + if (!$plugin instanceof ContextProviderInterface) { + return; + } + + foreach ($plugin->getProvidedContextDefinitions() as $name => $context_definition) { + if (isset($this->configuration['provides_mapping'][$name])) { + // Populate the state with the new variable that is provided by this + // plugin. That is necessary so that the integrity check in subsequent + // actions knows about the variable and does not throw violations. + $metadata_state->setDataDefinition( + $this->configuration['provides_mapping'][$name], + $context_definition->getDataDefinition() + ); + } + else { + $metadata_state->setDataDefinition($name, $context_definition->getDataDefinition()); + } + } + } + + /** + * Asserts additional metadata. + * + * @param CoreContextAwarePluginInterface $plugin + * The context aware plugin. + * @param \Drupal\rules\Engine\ExecutionMetadataStateInterface $metadata_state + * The execution metadata state. + */ + protected function assertMetadata(CoreContextAwarePluginInterface $plugin, ExecutionMetadataStateInterface $metadata_state) { + // If the plugin does not implement the Rules-enhanced interface, skip this. + if (!$plugin instanceof ContextAwarePluginInterface) { + return; + } + $changed_definitions = $plugin->assertMetadata($this->getSelectedData($metadata_state)); + + // Reverse the mapping and apply the changes. + foreach ($changed_definitions as $context_name => $definition) { + $selector = $this->configuration['context_mapping'][$context_name]; + // @todo: Deal with selectors matching not a context name. + if (strpos($selector, '.') === FALSE) { + $metadata_state->setDataDefinition($selector, $definition); + } + } + } + /** * Process data context on the plugin, usually before it gets executed. * diff --git a/src/Context/ContextProviderTrait.php b/src/Context/ContextProviderTrait.php index e0cd0847..7f4a6974 100644 --- a/src/Context/ContextProviderTrait.php +++ b/src/Context/ContextProviderTrait.php @@ -13,6 +13,9 @@ /** * A trait implementing the ContextProviderInterface. * + * This trait is intended for context aware plugins that want to provide + * context. + * * The trait requires the plugin to use configuration as defined by the * ContextConfig class. * diff --git a/src/Core/ConditionManager.php b/src/Core/ConditionManager.php index 27b0e4e1..c3bbd9bf 100644 --- a/src/Core/ConditionManager.php +++ b/src/Core/ConditionManager.php @@ -16,6 +16,16 @@ */ class ConditionManager extends CoreConditionManager { + /** + * {@inheritdoc} + * + * @return \Drupal\rules\Core\RulesConditionInterface|\Drupal\Core\Condition\ConditionInterface + * A fully configured plugin instance. + */ + public function createInstance($plugin_id, array $configuration = []) { + return parent::createInstance($plugin_id, $configuration); + } + /** * {@inheritdoc} */ diff --git a/src/Core/RulesActionBase.php b/src/Core/RulesActionBase.php index 1559c58f..431a797b 100644 --- a/src/Core/RulesActionBase.php +++ b/src/Core/RulesActionBase.php @@ -7,6 +7,7 @@ namespace Drupal\rules\Core; +use Drupal\Component\Plugin\Exception\ContextException; use Drupal\Core\Access\AccessResult; use Drupal\Core\Plugin\ContextAwarePluginBase; use Drupal\Core\Session\AccountInterface; @@ -31,10 +32,35 @@ abstract class RulesActionBase extends ContextAwarePluginBase implements RulesAc /** * {@inheritdoc} */ - public function refineContextDefinitions() { + public function getContextValue($name) { + try { + return parent::getContextValue($name); + } + catch (ContextException $e) { + // Catch the undocumented exception thrown when no context value is set + // for a required context. + // @todo: Remove once https://www.drupal.org/node/2677162 is fixed. + if (strpos($e->getMessage(), 'context is required') === FALSE) { + throw $e; + } + } + } + + /** + * {@inheritdoc} + */ + public function refineContextDefinitions(array $selected_data) { // Do not refine anything by default. } + /** + * {@inheritdoc} + */ + public function assertMetadata(array $selected_data) { + // Nothing to assert by default. + return []; + } + /** * {@inheritdoc} */ diff --git a/src/Core/RulesActionManagerInterface.php b/src/Core/RulesActionManagerInterface.php index 36f4a304..cb3e9ecd 100644 --- a/src/Core/RulesActionManagerInterface.php +++ b/src/Core/RulesActionManagerInterface.php @@ -20,4 +20,12 @@ */ interface RulesActionManagerInterface extends CategorizingPluginManagerInterface, ContextAwarePluginManagerInterface { + /** + * {@inheritdoc} + * + * @return \Drupal\rules\Core\RulesActionInterface + * A fully configured plugin instance. + */ + public function createInstance($plugin_id, array $configuration = []); + } diff --git a/src/Core/RulesConditionBase.php b/src/Core/RulesConditionBase.php index a7b5e3e8..9ac5d8ef 100644 --- a/src/Core/RulesConditionBase.php +++ b/src/Core/RulesConditionBase.php @@ -25,10 +25,35 @@ abstract class RulesConditionBase extends ConditionPluginBase implements RulesCo /** * {@inheritdoc} */ - public function refineContextDefinitions() { + public function refineContextDefinitions(array $selected_data) { // Do not refine anything by default. } + /** + * {@inheritdoc} + */ + public function assertMetadata(array $selected_data) { + // Nothing to assert by default. + return []; + } + + /** + * {@inheritdoc} + */ + public function getContextValue($name) { + try { + return parent::getContextValue($name); + } + catch (ContextException $e) { + // Catch the undocumented exception thrown when no context value is set + // for a required context. + // @todo: Remove once https://www.drupal.org/node/2677162 is fixed. + if (strpos($e->getMessage(), 'context is required') === FALSE) { + throw $e; + } + } + } + /** * {@inheritdoc} */ diff --git a/src/Core/RulesDefaultEventHandler.php b/src/Core/RulesDefaultEventHandler.php index f2297bae..1af70b63 100644 --- a/src/Core/RulesDefaultEventHandler.php +++ b/src/Core/RulesDefaultEventHandler.php @@ -22,6 +22,7 @@ public function getContextDefinitions() { $definition = $this->getPluginDefinition(); if ($this instanceof RulesConfigurableEventHandlerInterface) { $this->refineContextDefinitions(); + $definition = $this->getPluginDefinition(); } return !empty($definition['context']) ? $definition['context'] : []; } diff --git a/src/Engine/ActionExpressionContainer.php b/src/Engine/ActionExpressionContainer.php index c2677d90..fac597fe 100644 --- a/src/Engine/ActionExpressionContainer.php +++ b/src/Engine/ActionExpressionContainer.php @@ -10,12 +10,11 @@ use Drupal\Core\Plugin\ContainerFactoryPluginInterface; use Drupal\rules\Context\ContextConfig; use Drupal\rules\Exception\InvalidExpressionException; -use Symfony\Component\DependencyInjection\ContainerInterface; /** * Container for actions. */ -abstract class ActionExpressionContainer extends ExpressionBase implements ActionExpressionContainerInterface, ContainerFactoryPluginInterface { +abstract class ActionExpressionContainer extends ExpressionContainerBase implements ActionExpressionContainerInterface, ContainerFactoryPluginInterface { /** * List of actions that will be executed. @@ -47,18 +46,6 @@ public function __construct(array $configuration, $plugin_id, $plugin_definition } } - /** - * {@inheritdoc} - */ - public static function create(ContainerInterface $container, array $configuration, $plugin_id, $plugin_definition) { - return new static( - $configuration, - $plugin_id, - $plugin_definition, - $container->get('plugin.manager.rules_expression') - ); - } - /** * {@inheritdoc} */ @@ -73,15 +60,6 @@ public function addExpressionObject(ExpressionInterface $expression) { return $this; } - /** - * {@inheritdoc} - */ - public function addExpression($plugin_id, ContextConfig $config = NULL) { - return $this->addExpressionObject( - $this->expressionManager->createInstance($plugin_id, $config ? $config->toArray() : []) - ); - } - /** * {@inheritdoc} */ @@ -160,42 +138,4 @@ public function deleteExpression($uuid) { return FALSE; } - /** - * {@inheritdoc} - */ - public function checkIntegrity(ExecutionMetadataStateInterface $metadata_state) { - $violation_list = new IntegrityViolationList(); - foreach ($this->actions as $action) { - $action_violations = $action->checkIntegrity($metadata_state); - $violation_list->addAll($action_violations); - } - return $violation_list; - } - - /** - * {@inheritdoc} - */ - public function prepareExecutionMetadataState(ExecutionMetadataStateInterface $metadata_state, ExpressionInterface $until = NULL) { - if ($until) { - if ($this->getUuid() === $until->getUuid()) { - return TRUE; - } - foreach ($this->actions as $action) { - if ($action->getUuid() === $until->getUuid()) { - return TRUE; - } - $found = $action->prepareExecutionMetadataState($metadata_state, $until); - if ($found) { - return TRUE; - } - } - return FALSE; - } - - foreach ($this->actions as $action) { - $action->prepareExecutionMetadataState($metadata_state); - } - return TRUE; - } - } diff --git a/src/Engine/ConditionExpressionContainer.php b/src/Engine/ConditionExpressionContainer.php index 10452187..f23ac2da 100644 --- a/src/Engine/ConditionExpressionContainer.php +++ b/src/Engine/ConditionExpressionContainer.php @@ -10,17 +10,16 @@ use Drupal\Core\Plugin\ContainerFactoryPluginInterface; use Drupal\rules\Context\ContextConfig; use Drupal\rules\Exception\InvalidExpressionException; -use Symfony\Component\DependencyInjection\ContainerInterface; /** * Container for conditions. */ -abstract class ConditionExpressionContainer extends ExpressionBase implements ConditionExpressionContainerInterface, ContainerFactoryPluginInterface { +abstract class ConditionExpressionContainer extends ExpressionContainerBase implements ConditionExpressionContainerInterface, ContainerFactoryPluginInterface { /** * List of conditions that are evaluated. * - * @var \Drupal\rules\Core\RulesConditionInterface[] + * @var \Drupal\rules\Engine\ConditionExpressionInterface[] */ protected $conditions = []; @@ -47,18 +46,6 @@ public function __construct(array $configuration, $plugin_id, array $plugin_defi } } - /** - * {@inheritdoc} - */ - public static function create(ContainerInterface $container, array $configuration, $plugin_id, $plugin_definition) { - return new static( - $configuration, - $plugin_id, - $plugin_definition, - $container->get('plugin.manager.rules_expression') - ); - } - /** * {@inheritdoc} */ @@ -73,15 +60,6 @@ public function addExpressionObject(ExpressionInterface $expression) { return $this; } - /** - * {@inheritdoc} - */ - public function addExpression($plugin_id, ContextConfig $config = NULL) { - return $this->addExpressionObject( - $this->expressionManager->createInstance($plugin_id, $config ? $config->toArray() : []) - ); - } - /** * {@inheritdoc} */ @@ -188,42 +166,4 @@ public function deleteExpression($uuid) { return FALSE; } - /** - * {@inheritdoc} - */ - public function checkIntegrity(ExecutionMetadataStateInterface $metadata_state) { - $violation_list = new IntegrityViolationList(); - foreach ($this->conditions as $condition) { - $condition_violations = $condition->checkIntegrity($metadata_state); - $violation_list->addAll($condition_violations); - } - return $violation_list; - } - - /** - * {@inheritdoc} - */ - public function prepareExecutionMetadataState(ExecutionMetadataStateInterface $metadata_state, ExpressionInterface $until = NULL) { - if ($until) { - if ($this->getUuid() === $until->getUuid()) { - return TRUE; - } - foreach ($this->conditions as $condition) { - if ($condition->getUuid() === $until->getUuid()) { - return TRUE; - } - $found = $condition->prepareExecutionMetadataState($metadata_state, $until); - if ($found) { - return TRUE; - } - } - return FALSE; - } - - foreach ($this->conditions as $condition) { - $condition->prepareExecutionMetadataState($metadata_state); - } - return TRUE; - } - } diff --git a/src/Engine/ConditionExpressionInterface.php b/src/Engine/ConditionExpressionInterface.php index b10705bc..9f058e5b 100644 --- a/src/Engine/ConditionExpressionInterface.php +++ b/src/Engine/ConditionExpressionInterface.php @@ -12,6 +12,17 @@ */ interface ConditionExpressionInterface extends ExpressionInterface { + + /** + * Negates the result after evaluating this condition. + * + * @param bool $negate + * TRUE to indicate that the condition should be negated, FALSE otherwise. + * + * @return $this + */ + public function negate($negate = TRUE); + /** * Determines whether condition result will be negated. * diff --git a/src/Engine/ExecutionMetadataStateInterface.php b/src/Engine/ExecutionMetadataStateInterface.php index 4215393a..fdf637f3 100644 --- a/src/Engine/ExecutionMetadataStateInterface.php +++ b/src/Engine/ExecutionMetadataStateInterface.php @@ -79,12 +79,13 @@ public function removeDataDefinition($name); * @param string $property_path * The property path, example: "node:title:value". * @param string $langcode - * The langauge code. + * The language code. * * @return \Drupal\Core\TypedData\DataDefinitionInterface * A data definition if the property path could be applied. * * @throws \Drupal\rules\Exception\RulesIntegrityException + * Thrown if the property path is invalid. */ public function fetchDefinitionByPropertyPath($property_path, $langcode = LanguageInterface::LANGCODE_NOT_SPECIFIED); diff --git a/src/Engine/ExecutionState.php b/src/Engine/ExecutionState.php index 18eba781..21d8d972 100644 --- a/src/Engine/ExecutionState.php +++ b/src/Engine/ExecutionState.php @@ -178,6 +178,13 @@ public function saveChangesLater($selector) { return $this; } + /** + * {@inheritdoc} + */ + public function getAutoSaveSelectors() { + return array_keys($this->saveLater); + } + /** * {@inheritdoc} */ @@ -185,14 +192,10 @@ public function autoSave() { // Make changes permanent. foreach ($this->saveLater as $selector => $flag) { $typed_data = $this->fetchDataByPropertyPath($selector); - // The returned data can be NULL, only save it if we actually have - // something here. - if ($typed_data) { - // Things that can be saved must have a save() method, right? - // Saving is always done at the root of the typed data tree, for example - // on the entity level. - $typed_data->getRoot()->getValue()->save(); - } + // Things that can be saved must have a save() method, right? + // Saving is always done at the root of the typed data tree, for example + // on the entity level. + $typed_data->getRoot()->getValue()->save(); } return $this; } diff --git a/src/Engine/ExecutionStateInterface.php b/src/Engine/ExecutionStateInterface.php index c75833fc..65464fb4 100644 --- a/src/Engine/ExecutionStateInterface.php +++ b/src/Engine/ExecutionStateInterface.php @@ -121,6 +121,15 @@ public function fetchDataByPropertyPath($property_path, $langcode = NULL); */ public function saveChangesLater($selector); + /** + * Returns the list of variables that should be auto-saved after execution. + * + * @return string[] + * The list of data selectors that specify the target object to be saved. + * Example: node.uid.entity. + */ + public function getAutoSaveSelectors(); + /** * Saves all variables that have been marked for auto saving. * diff --git a/src/Engine/ExpressionContainerBase.php b/src/Engine/ExpressionContainerBase.php new file mode 100644 index 00000000..f652e2b3 --- /dev/null +++ b/src/Engine/ExpressionContainerBase.php @@ -0,0 +1,110 @@ +get('plugin.manager.rules_expression') + ); + } + + /** + * {@inheritdoc} + */ + public function addExpression($plugin_id, ContextConfig $config = NULL) { + return $this->addExpressionObject( + $this->expressionManager->createInstance($plugin_id, $config ? $config->toArray() : []) + ); + } + + /** + * Determines whether child-expressions are allowed to assert metadata. + * + * @return bool + * Whether child-expressions are allowed to assert metadata. + * + * @see \Drupal\rules\Engine\ExpressionInterface::prepareExecutionMetadataState() + */ + abstract protected function allowsMetadataAssertions(); + + /** + * {@inheritdoc} + */ + public function checkIntegrity(ExecutionMetadataStateInterface $metadata_state, $apply_assertions = TRUE) { + $violation_list = new IntegrityViolationList(); + $this->prepareExecutionMetadataStateBeforeTraversal($metadata_state); + $apply_assertions = $apply_assertions && $this->allowsMetadataAssertions(); + foreach ($this as $child_expression) { + $child_violations = $child_expression->checkIntegrity($metadata_state, $apply_assertions); + $violation_list->addAll($child_violations); + } + $this->prepareExecutionMetadataStateAfterTraversal($metadata_state); + return $violation_list; + } + + /** + * {@inheritdoc} + */ + public function prepareExecutionMetadataState(ExecutionMetadataStateInterface $metadata_state, ExpressionInterface $until = NULL, $apply_assertions = TRUE) { + if ($until && $this->getUuid() === $until->getUuid()) { + return TRUE; + } + $this->prepareExecutionMetadataStateBeforeTraversal($metadata_state); + $apply_assertions = $apply_assertions && $this->allowsMetadataAssertions(); + foreach ($this as $child_expression) { + $found = $child_expression->prepareExecutionMetadataState($metadata_state, $until, $apply_assertions); + // If the expression was found, we need to stop. + if ($found) { + return TRUE; + } + } + $this->prepareExecutionMetadataStateAfterTraversal($metadata_state); + } + + /** + * Prepares execution metadata state before traversing through children. + * + * @see ::prepareExecutionMetadataState() + * @see ::checkIntegrity() + */ + protected function prepareExecutionMetadataStateBeforeTraversal(ExecutionMetadataStateInterface $metadata_state) { + // Any pre-traversal preparations need to be added here. + } + + /** + * Prepares execution metadata state after traversing through children. + * + * @see ::prepareExecutionMetadataState() + * @see ::checkIntegrity() + */ + protected function prepareExecutionMetadataStateAfterTraversal(ExecutionMetadataStateInterface $metadata_state) { + // Any post-traversal preparations need to be added here. + } + +} diff --git a/src/Engine/ExpressionInterface.php b/src/Engine/ExpressionInterface.php index a910972d..7c2be02b 100644 --- a/src/Engine/ExpressionInterface.php +++ b/src/Engine/ExpressionInterface.php @@ -68,20 +68,6 @@ public function setRoot(ExpressionInterface $root); */ public function getLabel(); - /** - * Verifies that this expression is configured correctly. - * - * Example: all variable names used in the expression are available. - * - * @param \Drupal\rules\Engine\ExecutionMetadataStateInterface $metadata_state - * The configuration state used to hold available data definitions of - * variables. - * - * @return \Drupal\rules\Engine\IntegrityViolationList - * A list object containing \Drupal\rules\Engine\IntegrityViolation objects. - */ - public function checkIntegrity(ExecutionMetadataStateInterface $metadata_state); - /** * Returns the UUID of this expression if it is nested in another expression. * @@ -99,29 +85,67 @@ public function getUuid(); public function setUuid($uuid); /** - * Prepares the execution metadata state by adding variables to it. + * Verifies that this expression is configured correctly. + * + * Example: All configured data selectors must be valid. + * + * Note that for checking integrity the execution metadata state must be + * passed prepared as achieved by ::prepareExecutionMetadataState() and the + * expression must apply all metadata state preparations during its integrity + * check as it does in ::prepareExecutionMetadataState(). + * This allows for efficient integrity checks of expression trees; e.g. see + * \Drupal\rules\Engine\ActionExpressionContainer::checkIntegrity(). + * + * @param \Drupal\rules\Engine\ExecutionMetadataStateInterface $metadata_state + * The execution metadata state, prepared until right before this + * expression. + * @param bool $apply_assertions + * (optional) Whether to apply metadata assertions while preparing the + * execution metadata state. Defaults to TRUE. + * + * @return \Drupal\rules\Engine\IntegrityViolationList + * A list object containing \Drupal\rules\Engine\IntegrityViolation objects. + * + * @see ::prepareExecutionMetadataState() + */ + public function checkIntegrity(ExecutionMetadataStateInterface $metadata_state, $apply_assertions = TRUE); + + /** + * Prepares the execution metadata state by adding metadata to it. * * If this expression contains other expressions then the metadata state is * set up recursively. If a $until expression is specified then the setup will - * stop right before that expression. This is useful for inspecting the state - * at a certain point in the expression tree, for example to do autocompletion - * of available variables in the state. - * - * The difference to fully preparing the state is that not all variables are - * available in the middle of the expression tree. Preparing with + * stop right before that expression to calculate the state at this execution + * point. + * This is useful for inspecting the state at a certain point in the + * expression tree as needed during configuration, for example to do + * autocompletion of available variables in the state. + * + * The difference to fully preparing the state is that not necessarily all + * variables are available in the middle of the expression tree, as for + * example variables being added later are not added yet. Preparing with * $until = NULL reflects the execution metadata state at the end of the - * expression. + * expression execution. * * @param \Drupal\rules\Engine\ExecutionMetadataStateInterface $metadata_state - * The execution metadata state to populate variables in. + * The execution metadata state, prepared until right before this + * expression. * @param \Drupal\rules\Engine\ExpressionInterface $until - * (optional) A nested expression if this expression is a container. - * Preparation of the sate will happen right before that expression. - * - * @return bool - * TRUE if $until is NULL or the nested expression was found in the tree, - * FALSE otherwise. + * (optional) The expression at which metadata preparation should be + * stopped. The preparation of the state will be stopped right before that + * expression. + * @param bool $apply_assertions + * (optional) Whether to apply metadata assertions while preparing the + * execution metadata state. Defaults to TRUE. Metadata assertions should + * be only applied if the expression's execution is required for sub-sequent + * expressions being executed. For example, if a condition is optional as + * it is part of a logical OR expression, its assertions may not be applied. + * Defaults to TRUE. + * + * @return true|null + * True if the metadata has been prepared and the $until expression was + * found in the tree. Null otherwise. */ - public function prepareExecutionMetadataState(ExecutionMetadataStateInterface $metadata_state, ExpressionInterface $until = NULL); + public function prepareExecutionMetadataState(ExecutionMetadataStateInterface $metadata_state, ExpressionInterface $until = NULL, $apply_assertions = TRUE); } diff --git a/src/Engine/IntegrityViolationList.php b/src/Engine/IntegrityViolationList.php index d48ab532..c62a40d2 100644 --- a/src/Engine/IntegrityViolationList.php +++ b/src/Engine/IntegrityViolationList.php @@ -30,6 +30,35 @@ public function addAll(IntegrityViolationList $other_list) { } } + /** + * Returns the violation at a given offset. + * + * @param int $offset + * The offset of the violation. + * + * @return \Drupal\rules\Engine\IntegrityViolationInterface + * The violation. + * + * @throws \OutOfBoundsException + * Thrown if the offset does not exist. + */ + public function get($offset) { + return $this->offsetGet($offset); + } + + /** + * Returns whether the given offset exists. + * + * @param int $offset + * The violation offset. + * + * @return bool + * Whether the offset exists. + */ + public function has($offset) { + return $this->offsetExists($offset); + } + /** * Creates a new violation with the message and adds it to this list. * diff --git a/src/Engine/RulesComponent.php b/src/Engine/RulesComponent.php index 1e500c50..27049e64 100644 --- a/src/Engine/RulesComponent.php +++ b/src/Engine/RulesComponent.php @@ -69,7 +69,7 @@ public static function create(ExpressionInterface $expression) { public static function createFromConfiguration(array $configuration) { $configuration += [ 'context_definitions' => [], - 'provided_context' => [], + 'provided_context_definitions' => [], ]; // @todo: Can we improve this use dependency injection somehow? $expression_manager = \Drupal::service('plugin.manager.rules_expression'); @@ -78,7 +78,7 @@ public static function createFromConfiguration(array $configuration) { foreach ($configuration['context_definitions'] as $name => $definition) { $component->addContextDefinition($name, ContextDefinition::createFromArray($definition)); } - foreach ($configuration['provided_context'] as $name) { + foreach ($configuration['provided_context_definitions'] as $name => $definition) { $component->provideContext($name); } return $component; @@ -122,7 +122,7 @@ public function getConfiguration() { 'context_definitions' => array_map(function (ContextDefinitionInterface $definition) { return $definition->toArray(); }, $this->contextDefinitions), - 'provided_context' => $this->providedContext, + 'provided_context_definitions' => $this->providedContext, ]; } diff --git a/src/Entity/ReactionRuleConfig.php b/src/Entity/ReactionRuleConfig.php index 8620d9ec..9efed6cf 100644 --- a/src/Entity/ReactionRuleConfig.php +++ b/src/Entity/ReactionRuleConfig.php @@ -37,7 +37,7 @@ * config_export = { * "id", * "label", - * "event", + * "events", * "module", * "description", * "tag", @@ -116,11 +116,16 @@ class ReactionRuleConfig extends ConfigEntityBase implements RulesUiComponentPro protected $module = 'rules'; /** - * The event name this reaction rule is reacting on. + * The events this reaction rule is reacting on. * - * @var string + * Events array. The array is numerically indexed and contains arrays with the + * following structure: + * - event_name: String with the event machine name. + * - configuration: An array containing the event configuration. + * + * @var array */ - protected $event; + protected $events = []; /** * Sets a Rules expression instance for this Reaction rule. @@ -159,7 +164,7 @@ public function getExpression() { */ public function getComponent() { $component = RulesComponent::create($this->getExpression()); - $component->addContextDefinitionsForEvents([$this->getEvent()]); + $component->addContextDefinitionsForEvents($this->getEventBaseNames()); return $component; } @@ -222,10 +227,55 @@ public function getTag() { } /** - * Returns the event on which this rule will trigger. + * Gets configuration of all events the rule is reacting on. + * + * @return array + * The events array. The array is numerically indexed and contains arrays + * with the following structure: + * - event_name: String with the event machine name. + * - configuration: An array containing the event configuration. + */ + public function getEvents() { + return $this->events; + } + + /** + * Gets fully qualified names of all events the rule is reacting on. + * + * @return string[] + * The array of fully qualified event names of the rule. + */ + public function getEventNames() { + $names = []; + foreach ($this->events as $event) { + $names[] = $event['event_name']; + } + return $names; + } + + /** + * Gets the base names of all events the rule is reacting on. + * + * For a configured event name like {EVENT_NAME}--{SUFFIX}, the base event + * name {EVENT_NAME} is returned. + * + * @return string[] + * The array of base event names of the rule. + * + * @see \Drupal\rules\Core\RulesConfigurableEventHandlerInterface::getEventNameSuffix() */ - public function getEvent() { - return $this->event; + public function getEventBaseNames() { + $names = []; + foreach ($this->events as $event) { + $event_name = $event['event_name']; + if (strpos($event_name, '--') !== FALSE) { + // Cut off any suffix from a configured event name. + $parts = explode('--', $event_name, 2); + $event_name = $parts[0]; + } + $names[] = $event_name; + } + return $names; } /** diff --git a/src/Entity/ReactionRuleStorage.php b/src/Entity/ReactionRuleStorage.php index e03cab6d..50baa766 100644 --- a/src/Entity/ReactionRuleStorage.php +++ b/src/Entity/ReactionRuleStorage.php @@ -95,10 +95,10 @@ public static function createInstance(ContainerInterface $container, EntityTypeI protected function getRegisteredEvents() { $events = []; foreach ($this->loadMultiple() as $rules_config) { - $event = $rules_config->getEvent(); - $event = $this->eventManager->getEventBaseName($event); - if ($event && !isset($events[$event])) { - $events[$event] = $event; + foreach ($rules_config->getEventBaseNames() as $event_name) { + if (!isset($events[$event_name])) { + $events[$event_name] = $event_name; + } } } return $events; @@ -119,10 +119,13 @@ public function save(EntityInterface $entity) { // After the reaction rule is saved, we need to rebuild the container, // otherwise the reaction rule will not fire. However, we can do an - // optimization: if the event was already registered before, we do not have - // to rebuild the container. - if (empty($events_before[$entity->getEvent()])) { - $this->drupalKernel->rebuildContainer(); + // optimization: if every event was already registered before, we do not + // have to rebuild the container. + foreach ($entity->getEventBaseNames() as $event_name) { + if (empty($events_before[$event_name])) { + $this->drupalKernel->rebuildContainer(); + break; + } } return $return; diff --git a/src/Entity/RulesComponentConfig.php b/src/Entity/RulesComponentConfig.php index 3c638cf5..e4962881 100644 --- a/src/Entity/RulesComponentConfig.php +++ b/src/Entity/RulesComponentConfig.php @@ -164,8 +164,10 @@ public function updateFromComponent(RulesComponent $component) { */ public function getContextDefinitions() { $definitions = []; - foreach ($this->component['context_definitions'] as $name => $definition) { - $definitions[$name] = ContextDefinition::createFromArray($definition); + if (!empty($this->component['context_definitions'])) { + foreach ($this->component['context_definitions'] as $name => $definition) { + $definitions[$name] = ContextDefinition::createFromArray($definition); + } } return $definitions; } @@ -187,25 +189,34 @@ public function setContextDefinitions($definitions) { } /** - * Returns the names of context that is provided back to the caller. + * Gets the definitions of the provided context. * - * @return string[] - * The names of the context that is provided back. + * @return \Drupal\rules\Context\ContextDefinitionInterface[] + * The array of context definition, keyed by context name. */ - public function getProvidedContext() { - return $this->component['provided_context']; + public function getProvidedContextDefinitions() { + $definitions = []; + if (!empty($this->component['provided_context_definitions'])) { + foreach ($this->component['provided_context_definitions'] as $name => $definition) { + $definitions[$name] = ContextDefinition::createFromArray($definition); + } + } + return $definitions; } /** - * Sets the names of the context that is provided back to the caller. + * Sets the definitions of the provided context. * - * @param string[] $names - * The names of the context that is provided back. + * @param \Drupal\rules\Context\ContextDefinitionInterface[] $definitions + * The array of context definitions, keyed by context name. * * @return $this */ - public function setProvidedContext($names) { - $this->component['provided_context'] = $names; + public function setProvidedContextDefinitions($definitions) { + $this->component['provided_context_definitions'] = []; + foreach ($definitions as $name => $definition) { + $this->component['provided_context_definitions'][$name] = $definition->toArray(); + } return $this; } diff --git a/src/EventHandler/ConfigurableEventHandlerEntityBundle.php b/src/EventHandler/ConfigurableEventHandlerEntityBundle.php index 86baa041..58362796 100644 --- a/src/EventHandler/ConfigurableEventHandlerEntityBundle.php +++ b/src/EventHandler/ConfigurableEventHandlerEntityBundle.php @@ -15,6 +15,42 @@ */ class ConfigurableEventHandlerEntityBundle extends ConfigurableEventHandlerBase { + /** + * The bundles information for the entity. + * + * @var array + */ + protected $bundlesInfo; + + /** + * The entity info plugin definition. + * + * @var mixed + */ + protected $entityInfo; + + /** + * The entity type. + * + * @var string + */ + protected $entityTypeId; + + /** + * {@inheritdoc} + */ + public function __construct(array $configuration, $plugin_id, $plugin_definition) { + parent::__construct($configuration, $plugin_id, $plugin_definition); + $this->entityTypeId = $plugin_definition['entity_type_id']; + // @todo: This needs to use dependency injection. + $this->entityInfo = \Drupal::entityTypeManager()->getDefinition($this->entityTypeId); + // @tdo: use EntityTypeBundleInfo service. + $this->bundlesInfo = \Drupal::entityManager()->getBundleInfo($this->entityTypeId); + if (!$this->bundlesInfo) { + throw new \InvalidArgumentException('Unsupported event name passed.'); + } + } + /** * {@inheritdoc} */ @@ -30,42 +66,64 @@ public static function determineQualifiedEvents(Event $event, $event_name, array * {@inheritdoc} */ public function summary() { - // Nothing to do by default. + $bundle = $this->configuration['bundle']; + $bundle_label = isset($this->bundlesInfo[$bundle]['label']) ? $this->bundlesInfo[$bundle]['label'] : $bundle; + $suffix = isset($bundle) ? ' ' . t('of @bundle-key %name', array('@bundle-key' => $this->entityInfo->getBundleLabel(), '%name' => $bundle_label)) : ''; + return $this->pluginDefinition['label']->render() . $suffix; } /** * {@inheritdoc} */ public function buildConfigurationForm(array $form, FormStateInterface $form_state) { - // Nothing to do by default. + $form['bundle'] = array( + '#type' => 'select', + '#title' => t('Restrict by @bundle', array('@bundle' => $this->entityInfo->getBundleLabel())), + '#description' => t('If you need to filter for multiple values, either add multiple events or use the "Entity is of bundle" condition instead.'), + '#default_value' => $this->configuration['bundle'], + '#empty_value' => '', + ); + foreach ($this->bundlesInfo as $name => $bundle_info) { + $form['bundle']['#options'][$name] = $bundle_info['label']; + } + return $form; + } + + /** + * {@inheritdoc} + */ + public function extractConfigurationFormValues(array &$form, FormStateInterface $form_state) { + $this->configuration['bundle'] = $form_state->getValue('bundle'); } /** * {@inheritdoc} */ public function validate() { - // Nothing to check by default. + // Nothing to validate. } /** * {@inheritdoc} */ public function getEventNameSuffix() { - // Nothing to do by default. + return isset($this->configuration['bundle']) ? $this->configuration['bundle'] : FALSE; } /** * {@inheritdoc} */ public function refineContextDefinitions() { - // Nothing to refine by default. + if ($bundle = $this->getEventNameSuffix()) { + $this->pluginDefinition['context']['entity']->setBundles([$bundle]); + } } /** * {@inheritdoc} */ public function calculateDependencies() { - // Nothing to calculate by default. + // @todo: Implement. } } diff --git a/src/EventSubscriber/GenericEventSubscriber.php b/src/EventSubscriber/GenericEventSubscriber.php index ce3edd81..4fa13f64 100644 --- a/src/EventSubscriber/GenericEventSubscriber.php +++ b/src/EventSubscriber/GenericEventSubscriber.php @@ -125,7 +125,7 @@ public function onRulesEvent(Event $event, $event_name) { // another rule. foreach ($triggered_events as $triggered_event) { // @todo Only load active reaction rules here. - $configs = $storage->loadByProperties(['event' => $triggered_event]); + $configs = $storage->loadByProperties(['events.*.event_name' => $triggered_event]); // Loop over all rules and execute them. foreach ($configs as $config) { diff --git a/src/Form/ReactionRuleAddForm.php b/src/Form/ReactionRuleAddForm.php index 2fb521bd..7b1869e7 100644 --- a/src/Form/ReactionRuleAddForm.php +++ b/src/Form/ReactionRuleAddForm.php @@ -8,6 +8,7 @@ namespace Drupal\rules\Form; use Drupal\Core\Form\FormStateInterface; +use Drupal\rules\Core\RulesConfigurableEventHandlerInterface; use Drupal\rules\Core\RulesEventManager; use Symfony\Component\DependencyInjection\ContainerInterface; @@ -65,17 +66,43 @@ public function form(array $form, FormStateInterface $form_state) { } } - $form['event'] = [ + $form['events']['#tree'] = TRUE; + $form['events'][]['event_name'] = [ '#type' => 'select', '#title' => $this->t('React on event'), '#options' => $options, '#required' => TRUE, + '#ajax' => $this->getDefaultAjax(), '#description' => $this->t('Whenever the event occurs, rule evaluation is triggered.'), + '#executes_submit_callback' => array('::submitForm'), ]; + $form['event_configuration'] = array(); + if ($values = $form_state->getValue('events')) { + $event_name = $values[0]['event_name']; + if ($handler = $this->getEventHandler($event_name)) { + $form['event_configuration'] = $handler->buildConfigurationForm(array(), $form_state); + } + } + return $form; } + /** + * {@inheritdoc} + */ + public function buildEntity(array $form, FormStateInterface $form_state) { + $entity = parent::buildEntity($form, $form_state); + foreach ($entity->getEventBaseNames() as $event_name) { + if ($handler = $this->getEventHandler($event_name)) { + $handler->extractConfigurationFormValues($form['event_configuration'], $form_state); + $entity->set('configuration', $handler->getConfiguration()); + $entity->set('events', [['event_name' => $event_name . '--' . $handler->getConfiguration()['bundle']]]); + } + } + return $entity; + } + /** * {@inheritdoc} */ @@ -86,4 +113,26 @@ public function save(array $form, FormStateInterface $form_state) { $form_state->setRedirect('entity.rules_reaction_rule.edit_form', ['rules_reaction_rule' => $this->entity->id()]); } + /** + * Gets event handler class. + * + * Currently event handler is available only when the event is configurable. + * + * @param $event_name + * The event base name. + * @param array $configuration + * The event configuration. + * + * @return \Drupal\rules\Core\RulesConfigurableEventHandlerInterface|null + * The event handler, null if there is no proper handler. + */ + protected function getEventHandler($event_name, $configuration = []) { + $event_definition = $this->eventManager->getDefinition($event_name); + $handler_class = $event_definition['class']; + if (is_subclass_of($handler_class, RulesConfigurableEventHandlerInterface::class)) { + $handler = new $handler_class($configuration, $this->eventManager->getEventBaseName($event_name), $event_definition); + return $handler; + } + } + } diff --git a/src/Form/ReactionRuleEditForm.php b/src/Form/ReactionRuleEditForm.php index 1a876c44..4cf4b8c7 100644 --- a/src/Form/ReactionRuleEditForm.php +++ b/src/Form/ReactionRuleEditForm.php @@ -71,12 +71,18 @@ protected function prepareEntity() { * {@inheritdoc} */ public function form(array $form, FormStateInterface $form_state) { - $event_name = $this->entity->getEvent(); - $event_definition = $this->eventManager->getDefinition($event_name); - $form['event']['#markup'] = $this->t('Event: @label (@name)', [ - '@label' => $event_definition['label'], - '@name' => $event_name, - ]); + foreach ($this->entity->getEventNames() as $key => $event_name) { + $event_base_name = $this->eventManager->getEventBaseName($event_name); + $event_definition = $this->eventManager->getDefinition($event_base_name); + $form['events'][$key] = [ + '#type' => 'item', + '#title' => $this->t('Events:'), + '#markup' => $this->t('@label (@name)', [ + '@label' => $event_definition['label'], + '@name' => $event_name, + ]), + ]; + } $form = $this->rulesUiHandler->getForm()->buildForm($form, $form_state); return parent::form($form, $form_state); } diff --git a/src/Form/RulesComponentFormBase.php b/src/Form/RulesComponentFormBase.php index ccb8c6dd..a29c7b90 100644 --- a/src/Form/RulesComponentFormBase.php +++ b/src/Form/RulesComponentFormBase.php @@ -19,6 +19,10 @@ abstract class RulesComponentFormBase extends EntityForm { * {@inheritdoc} */ public function form(array $form, FormStateInterface $form_state) { + // Specify the wrapper div used by #ajax. + $form['#prefix'] = '
'; + $form['#suffix'] = '
'; + $form['settings'] = [ '#type' => 'details', '#title' => $this->t('Settings'), @@ -78,4 +82,32 @@ public function exists($id) { return (bool) $this->entityTypeManager->getStorage($type)->load($id); } + /** + * Get default form #ajax properties. + * + * @param string $effect + * (optional) The jQuery effect to use when placing the new HTML (used with + * 'wrapper'). Valid options are 'none' (default), 'slide', or 'fade'. + * + * @return array + */ + public function getDefaultAjax($effect = 'none') { + return array( + 'callback' => '::reloadForm', + 'wrapper' => 'rules-form-wrapper', + 'effect' => $effect, + 'speed' => 'fast', + ); + } + + /** + * Ajax callback to reload the form. + * + * @return array + * The reloaded form. + */ + public function reloadForm(array $form, FormStateInterface $form_state) { + return $form; + } + } diff --git a/src/Plugin/Condition/DataComparison.php b/src/Plugin/Condition/DataComparison.php index 27f6cdda..77d0c7db 100644 --- a/src/Plugin/Condition/DataComparison.php +++ b/src/Plugin/Condition/DataComparison.php @@ -21,11 +21,10 @@ * label = @Translation("Data to compare"), * description = @Translation("The data to be checked to be empty, specified by using a data selector, e.g. 'node:uid:entity:name:value'.") * ), - * "operator" = @ContextDefinition("string", + * "operation" = @ContextDefinition("string", * label = @Translation("Operator"), - * description = @Translation("The comparison operator."), + * description = @Translation("The comparison operation."), * default_value = "==", - * required = FALSE * ), * "value" = @ContextDefinition("any", * label = @Translation("Data value"), @@ -44,8 +43,8 @@ class DataComparison extends RulesConditionBase { * * @param mixed $data * Supplied data to test. - * @param string $operator - * Data comparison operator. Typically one of: + * @param string $operation + * Data comparison operation. Typically one of: * - "==" * - "<" * - ">" @@ -57,9 +56,9 @@ class DataComparison extends RulesConditionBase { * @return bool * The evaluation of the condition. */ - protected function doEvaluate($data, $operator, $value) { - $operator = $operator ? $operator : '=='; - switch ($operator) { + protected function doEvaluate($data, $operation, $value) { + $operation = $operation ? $operation : '=='; + switch ($operation) { case '<': return $data < $value; @@ -82,4 +81,16 @@ protected function doEvaluate($data, $operator, $value) { } } + /** + * {@inheritdoc} + */ + public function refineContextDefinitions(array $selected_data) { + if (isset($selected_data['data'])) { + $this->pluginDefinition['context']['value']->setDataType($selected_data['data']->getDataType()); + if ($this->getContextValue('operation') == 'IN') { + $this->pluginDefinition['context']['value']->setMultiple(); + } + } + } + } diff --git a/src/Plugin/Condition/EntityIsOfBundle.php b/src/Plugin/Condition/EntityIsOfBundle.php index a8393e02..78de8c35 100644 --- a/src/Plugin/Condition/EntityIsOfBundle.php +++ b/src/Plugin/Condition/EntityIsOfBundle.php @@ -20,7 +20,8 @@ * context = { * "entity" = @ContextDefinition("entity", * label = @Translation("Entity"), - * description = @Translation("Specifies the entity for which to evaluate the condition.") + * description = @Translation("Specifies the entity for which to evaluate the condition."), + * assignment_restriction = "selector", * ), * "type" = @ContextDefinition("string", * label = @Translation("Type"), @@ -59,4 +60,18 @@ protected function doEvaluate(EntityInterface $entity, $type, $bundle) { return $entity_bundle == $bundle && $entity_type == $type; } + /** + * {@inheritdoc} + */ + public function assertMetadata(array $selected_data) { + // Assert the checked bundle. + $changed_definitions = []; + if (isset($selected_data['entity']) && $bundle = $this->getContextValue('bundle')) { + $changed_definitions['entity'] = clone $selected_data['entity']; + $bundles = is_array($bundle) ? $bundle : [$bundle]; + $changed_definitions['entity']->setBundles($bundles); + } + return $changed_definitions; + } + } diff --git a/src/Plugin/RulesAction/EntityCreate.php b/src/Plugin/RulesAction/EntityCreate.php index fa733b22..aafd465f 100644 --- a/src/Plugin/RulesAction/EntityCreate.php +++ b/src/Plugin/RulesAction/EntityCreate.php @@ -38,6 +38,13 @@ class EntityCreate extends RulesActionBase implements ContainerFactoryPluginInte */ protected $entityTypeId; + /** + * The entity bundle key used for the entity type. + * + * @var string + */ + protected $bundleKey; + /** * Constructs an EntityCreate object. * @@ -54,6 +61,7 @@ public function __construct(array $configuration, $plugin_id, $plugin_definition parent::__construct($configuration, $plugin_id, $plugin_definition); $this->storage = $storage; $this->entityTypeId = $plugin_definition['entity_type_id']; + $this->bundleKey = $plugin_definition['bundle_key']; } /** @@ -71,11 +79,11 @@ public static function create(ContainerInterface $container, array $configuratio /** * {@inheritdoc} */ - public function refineContextDefinitions() { + public function refineContextDefinitions(array $selected_data) { if ($type = $this->entityTypeId) { $data_type = "entity:$type"; - if ($bundle = $this->getContextValue('bundle')) { + if ($this->bundleKey && $bundle = $this->getContextValue($this->bundleKey)) { $data_type .= ":$bundle"; } diff --git a/src/Plugin/RulesAction/EntityCreateDeriver.php b/src/Plugin/RulesAction/EntityCreateDeriver.php index f40c1ab0..18f88425 100644 --- a/src/Plugin/RulesAction/EntityCreateDeriver.php +++ b/src/Plugin/RulesAction/EntityCreateDeriver.php @@ -9,11 +9,12 @@ use Drupal\Component\Plugin\Derivative\DeriverBase; use Drupal\Core\Entity\ContentEntityTypeInterface; -use Drupal\Core\Entity\EntityTypeManagerInterface; use Drupal\Core\Entity\EntityFieldManagerInterface; +use Drupal\Core\Entity\EntityTypeManagerInterface; use Drupal\Core\Plugin\Discovery\ContainerDeriverInterface; use Drupal\Core\StringTranslation\StringTranslationTrait; use Drupal\Core\StringTranslation\TranslationInterface; +use Drupal\Core\TypedData\DataReferenceDefinitionInterface; use Drupal\rules\Context\ContextDefinition; use Symfony\Component\DependencyInjection\ContainerInterface; @@ -83,15 +84,28 @@ public function getDerivativeDefinitions($base_plugin_definition) { // other required base fields. This matches the storage create() behavior, // where only the bundle requirement is enforced. $bundle_key = $entity_type->getKey('bundle'); + $this->derivatives[$entity_type_id]['bundle_key'] = $bundle_key; + $base_field_definitions = $this->entityFieldManager->getBaseFieldDefinitions($entity_type_id); foreach ($base_field_definitions as $field_name => $definition) { if ($field_name != $bundle_key && !$definition->isRequired()) { continue; } + $item_definition = $definition->getItemDefinition(); + $type_definition = $item_definition->getPropertyDefinition($item_definition->getMainPropertyName()); + + // If this is an entity reference then we expect the target type as + // context. + if ($type_definition instanceof DataReferenceDefinitionInterface) { + $type_definition->getTargetDefinition(); + } + $type = $type_definition->getDataType(); + $is_bundle = ($field_name == $bundle_key); $multiple = ($definition->getCardinality() === 1) ? FALSE : TRUE; - $context_definition = ContextDefinition::create($definition->getType()) + + $context_definition = ContextDefinition::create($type) ->setLabel($definition->getLabel()) ->setRequired($is_bundle) ->setMultiple($multiple) diff --git a/src/Plugin/RulesAction/EntityFetchByField.php b/src/Plugin/RulesAction/EntityFetchByField.php index 281726fe..8392938d 100644 --- a/src/Plugin/RulesAction/EntityFetchByField.php +++ b/src/Plugin/RulesAction/EntityFetchByField.php @@ -90,7 +90,7 @@ public static function create(ContainerInterface $container, array $configuratio /** * {@inheritdoc} */ - public function refineContextDefinitions() { + public function refineContextDefinitions(array $selected_data) { if ($type = $this->getContextValue('type')) { $this->pluginDefinition['provides']['entity_fetched']->setDataType("entity:$type"); } diff --git a/src/Plugin/RulesAction/EntityFetchById.php b/src/Plugin/RulesAction/EntityFetchById.php index 9815ad5a..61a23020 100644 --- a/src/Plugin/RulesAction/EntityFetchById.php +++ b/src/Plugin/RulesAction/EntityFetchById.php @@ -81,7 +81,7 @@ public static function create(ContainerInterface $container, array $configuratio /** * {@inheritdoc} */ - public function refineContextDefinitions() { + public function refineContextDefinitions(array $selected_data) { if ($type = $this->getContextValue('type')) { $this->pluginDefinition['provides']['entity_fetched']->setDataType("entity:$type"); } diff --git a/src/Plugin/RulesAction/EntitySave.php b/src/Plugin/RulesAction/EntitySave.php index e2743959..1209ad14 100644 --- a/src/Plugin/RulesAction/EntitySave.php +++ b/src/Plugin/RulesAction/EntitySave.php @@ -25,7 +25,7 @@ * "immediate" = @ContextDefinition("boolean", * label = @Translation("Force saving immediately"), * description = @Translation("Usually saving is postponed till the end of the evaluation, so that multiple saves can be fold into one. If this set, saving is forced to happen immediately."), - * default_value = NULL, + * default_value = FALSE, * required = FALSE * ) * } diff --git a/src/Plugin/RulesAction/RulesComponentAction.php b/src/Plugin/RulesAction/RulesComponentAction.php new file mode 100644 index 00000000..491e688b --- /dev/null +++ b/src/Plugin/RulesAction/RulesComponentAction.php @@ -0,0 +1,129 @@ +storage = $storage; + $this->componentId = $plugin_definition['component_id']; + } + + /** + * {@inheritdoc} + */ + public static function create(ContainerInterface $container, array $configuration, $plugin_id, $plugin_definition) { + return new static( + $configuration, + $plugin_id, + $plugin_definition, + $container->get('entity_type.manager')->getStorage('rules_component') + ); + } + + /** + * {@inheritdoc} + */ + public function execute() { + $rules_config = $this->storage->load($this->componentId); + + // Setup an isolated execution state for this expression and pass on the + // necessary context. + $rules_component = $rules_config->getComponent(); + foreach ($this->getContexts() as $context_name => $context) { + // Pass through the already existing typed data objects to avoid creating + // them from scratch. + $rules_component->getState()->setVariableData($context_name, $context->getContextData()); + } + + // We don't use RulesComponent::execute() here since we don't want to + // auto-save immediately. + $state = $rules_component->getState(); + $expression = $rules_component->getExpression(); + $expression->executeWithState($state); + + // Postpone auto-saving to the parent expression triggering this action. + foreach ($state->getAutoSaveSelectors() as $selector) { + $parts = explode('.', $selector); + $context_name = reset($parts); + if (array_key_exists($context_name, $this->context)) { + $this->saveLater[] = $context_name; + } + else { + // Otherwise we need to save here since it will not happen in the parent + // execution. + $typed_data = $state->fetchDataByPropertyPath($selector); + // Things that can be saved must have a save() method, right? + // Saving is always done at the root of the typed data tree, for example + // on the entity level. + $typed_data->getRoot()->getValue()->save(); + } + } + + foreach ($this->getProvidedContextDefinitions() as $name => $definition) { + $this->setProvidedValue($name, $state->getVariable($name)); + } + } + + /** + * {@inheritdoc} + */ + public function autoSaveContext() { + return $this->saveLater; + } + +} diff --git a/src/Plugin/RulesAction/RulesComponentActionDeriver.php b/src/Plugin/RulesAction/RulesComponentActionDeriver.php new file mode 100644 index 00000000..85329f77 --- /dev/null +++ b/src/Plugin/RulesAction/RulesComponentActionDeriver.php @@ -0,0 +1,82 @@ +storage = $storage; + $this->expressionManager = $expression_manager; + } + + /** + * {@inheritdoc} + */ + public static function create(ContainerInterface $container, $base_plugin_id) { + return new static( + $container->get('entity_type.manager')->getStorage('rules_component'), + $container->get('plugin.manager.rules_expression') + ); + } + + /** + * {@inheritdoc} + */ + public function getDerivativeDefinitions($base_plugin_definition) { + $rules_components = $this->storage->loadMultiple(); + foreach ($rules_components as $rules_component) { + + $component_config = $rules_component->get('component'); + $expression_definition = $this->expressionManager->getDefinition($component_config['expression']['id']); + + $this->derivatives[$rules_component->id()] = [ + 'label' => $this->t('@expression_type: @label', [ + '@expression_type' => $expression_definition['label'], + '@label' => $rules_component->label(), + ]), + 'category' => $this->t('Components'), + 'component_id' => $rules_component->id(), + 'context' => $rules_component->getContextDefinitions(), + 'provides' => $rules_component->getProvidedContextDefinitions(), + ] + $base_plugin_definition; + } + + return $this->derivatives; + } + +} diff --git a/src/Plugin/RulesAction/VariableAdd.php b/src/Plugin/RulesAction/VariableAdd.php index 3b5e8ff7..94d9be9c 100644 --- a/src/Plugin/RulesAction/VariableAdd.php +++ b/src/Plugin/RulesAction/VariableAdd.php @@ -49,8 +49,9 @@ protected function doExecute($type, $value) { /** * {@inheritdoc} */ - public function refineContextDefinitions() { + public function refineContextDefinitions(array $selected_data) { if ($type = $this->getContextValue('type')) { + $this->pluginDefinition['context']['value']->setDataType($type); $this->pluginDefinition['provides']['variable_added']->setDataType($type); } } diff --git a/src/Plugin/RulesExpression/ActionSet.php b/src/Plugin/RulesExpression/ActionSet.php index a8470d56..17fef135 100644 --- a/src/Plugin/RulesExpression/ActionSet.php +++ b/src/Plugin/RulesExpression/ActionSet.php @@ -21,6 +21,13 @@ */ class ActionSet extends ActionExpressionContainer { + /** + * {@inheritdoc} + */ + protected function allowsMetadataAssertions() { + return TRUE; + } + /** * {@inheritdoc} */ diff --git a/src/Plugin/RulesExpression/Rule.php b/src/Plugin/RulesExpression/Rule.php index b5a5b913..10fadcbe 100644 --- a/src/Plugin/RulesExpression/Rule.php +++ b/src/Plugin/RulesExpression/Rule.php @@ -103,8 +103,7 @@ public function executeWithState(ExecutionStateInterface $state) { * {@inheritdoc} */ public function addCondition($condition_id, ContextConfig $config = NULL) { - $this->conditions->addCondition($condition_id, $config); - return $this; + return $this->conditions->addCondition($condition_id, $config); } /** @@ -126,8 +125,7 @@ public function setConditions(ConditionExpressionContainerInterface $conditions) * {@inheritdoc} */ public function addAction($action_id, ContextConfig $config = NULL) { - $this->actions->addAction($action_id, $config); - return $this; + return $this->actions->addAction($action_id, $config); } /** @@ -215,26 +213,24 @@ public function deleteExpression($uuid) { /** * {@inheritdoc} */ - public function checkIntegrity(ExecutionMetadataStateInterface $metadata_state) { - $violation_list = $this->conditions->checkIntegrity($metadata_state); - $violation_list->addAll($this->actions->checkIntegrity($metadata_state)); + public function checkIntegrity(ExecutionMetadataStateInterface $metadata_state, $apply_assertions = TRUE) { + $violation_list = $this->conditions->checkIntegrity($metadata_state, $apply_assertions); + $violation_list->addAll($this->actions->checkIntegrity($metadata_state, $apply_assertions)); return $violation_list; } /** * {@inheritdoc} */ - public function prepareExecutionMetadataState(ExecutionMetadataStateInterface $metadata_state, ExpressionInterface $until = NULL) { - if ($until) { - $found = $this->conditions->prepareExecutionMetadataState($metadata_state, $until); - if (!$found) { - $found = $this->actions->prepareExecutionMetadataState($metadata_state, $until); - } - return $found; + public function prepareExecutionMetadataState(ExecutionMetadataStateInterface $metadata_state, ExpressionInterface $until = NULL, $apply_assertions = TRUE) { + // @todo: If the rule is nested, we may not pass assertions to following + // expressions as we do not know whether the rule fires at all. Should we + // clone the metadata state to ensure modifications stay local? + $found = $this->conditions->prepareExecutionMetadataState($metadata_state, $until, $apply_assertions); + if ($found) { + return TRUE; } - $this->conditions->prepareExecutionMetadataState($metadata_state); - $this->actions->prepareExecutionMetadataState($metadata_state); - return TRUE; + return $this->actions->prepareExecutionMetadataState($metadata_state, $until, $apply_assertions); } /** diff --git a/src/Plugin/RulesExpression/RulesAction.php b/src/Plugin/RulesExpression/RulesAction.php index 8ea59fc1..563f179f 100644 --- a/src/Plugin/RulesExpression/RulesAction.php +++ b/src/Plugin/RulesExpression/RulesAction.php @@ -7,9 +7,7 @@ namespace Drupal\rules\Plugin\RulesExpression; -use Drupal\Component\Plugin\Exception\ContextException; use Drupal\Core\Plugin\ContainerFactoryPluginInterface; -use Drupal\rules\Context\ContextHandlerTrait; use Drupal\rules\Context\DataProcessorManager; use Drupal\rules\Core\RulesActionManagerInterface; use Drupal\rules\Engine\ActionExpressionInterface; @@ -17,7 +15,7 @@ use Drupal\rules\Engine\ExecutionStateInterface; use Drupal\rules\Engine\ExpressionBase; use Drupal\rules\Engine\ExpressionInterface; -use Drupal\rules\Engine\IntegrityCheckTrait; +use Drupal\rules\Context\ContextHandlerIntegrityTrait; use Drupal\rules\Engine\IntegrityViolationList; use Symfony\Component\DependencyInjection\ContainerInterface; @@ -35,8 +33,7 @@ */ class RulesAction extends ExpressionBase implements ContainerFactoryPluginInterface, ActionExpressionInterface { - use ContextHandlerTrait; - use IntegrityCheckTrait; + use ContextHandlerIntegrityTrait; /** * The action manager used to instantiate the action plugin. @@ -97,16 +94,7 @@ public function setConfiguration(array $configuration) { public function executeWithState(ExecutionStateInterface $state) { $action = $this->actionManager->createInstance($this->configuration['action_id']); - // We have to forward the context values from our configuration to the - // action plugin. - $this->mapContext($action, $state); - - $action->refineContextdefinitions(); - - // Send the context value through configured data processor before executing - // the action. - $this->processData($action, $state); - + $this->prepareContext($action, $state); $action->execute(); $auto_saves = $action->autoSaveContext(); @@ -117,28 +105,7 @@ public function executeWithState(ExecutionStateInterface $state) { // Now that the action has been executed it can provide additional // context which we will have to pass back in the evaluation state. - $this->mapProvidedContext($action, $state); - } - - /** - * {@inheritdoc} - */ - public function getContextDefinitions() { - // Pass up the context definitions from the action plugin. - $definition = $this->actionManager->getDefinition($this->configuration['action_id']); - return !empty($definition['context']) ? $definition['context'] : []; - } - - /** - * {@inheritdoc} - */ - public function getContextDefinition($name) { - // Pass up the context definitions from the action plugin. - $definition = $this->actionManager->getDefinition($this->configuration['action_id']); - if (empty($definition['context'][$name])) { - throw new ContextException(sprintf("The %s context is not a valid context.", $name)); - } - return $definition['context'][$name]; + $this->addProvidedContext($action, $state); } /** @@ -165,7 +132,7 @@ public function getFormHandler() { /** * {@inheritdoc} */ - public function checkIntegrity(ExecutionMetadataStateInterface $metadata_state) { + public function checkIntegrity(ExecutionMetadataStateInterface $metadata_state, $apply_assertions = TRUE) { $violation_list = new IntegrityViolationList(); if (empty($this->configuration['action_id'])) { $violation_list->addViolationWithMessage($this->t('Action plugin ID is missing'), $this->getUuid()); @@ -180,19 +147,29 @@ public function checkIntegrity(ExecutionMetadataStateInterface $metadata_state) $action = $this->actionManager->createInstance($this->configuration['action_id']); - return $this->doCheckIntegrity($action, $metadata_state); + // Prepare and refine the context before checking integrity, such that any + // context definition changes are respected while checking. + $this->prepareContextWithMetadata($action, $metadata_state); + $result = $this->checkContextConfigIntegrity($action, $metadata_state); + $this->prepareExecutionMetadataState($metadata_state, NULL, $apply_assertions); + return $result; } /** * {@inheritdoc} */ - public function prepareExecutionMetadataState(ExecutionMetadataStateInterface $metadata_state, ExpressionInterface $until = NULL) { + public function prepareExecutionMetadataState(ExecutionMetadataStateInterface $metadata_state, ExpressionInterface $until = NULL, $apply_assertions = TRUE) { + if ($until && $this->getUuid() === $until->getUuid()) { + return TRUE; + } $action = $this->actionManager->createInstance($this->configuration['action_id']); - $this->addProvidedVariablesToExecutionMetadataState($action, $metadata_state); - if ($until) { - return FALSE; + // Make sure to refine context first, such that possibly refined definitions + // of provided context are respected. + $this->prepareContextWithMetadata($action, $metadata_state); + $this->addProvidedContextDefinitions($action, $metadata_state); + if ($apply_assertions) { + $this->assertMetadata($action, $metadata_state); } - return TRUE; } } diff --git a/src/Plugin/RulesExpression/RulesAnd.php b/src/Plugin/RulesExpression/RulesAnd.php index 06444105..82efd8f5 100644 --- a/src/Plugin/RulesExpression/RulesAnd.php +++ b/src/Plugin/RulesExpression/RulesAnd.php @@ -47,4 +47,13 @@ public function evaluate(ExecutionStateInterface $state) { return !empty($this->conditions); } + /** + * {@inheritdoc} + */ + protected function allowsMetadataAssertions() { + // If the AND is not negated, all child-expressions must be executed - thus + // assertions can be added it. + return !$this->isNegated(); + } + } diff --git a/src/Plugin/RulesExpression/RulesCondition.php b/src/Plugin/RulesExpression/RulesCondition.php index c0046538..d0637ac7 100644 --- a/src/Plugin/RulesExpression/RulesCondition.php +++ b/src/Plugin/RulesExpression/RulesCondition.php @@ -7,16 +7,15 @@ namespace Drupal\rules\Plugin\RulesExpression; -use Drupal\Core\Condition\ConditionManager; use Drupal\Core\Plugin\ContainerFactoryPluginInterface; -use Drupal\rules\Context\ContextHandlerTrait; use Drupal\rules\Context\DataProcessorManager; +use Drupal\rules\Core\ConditionManager; use Drupal\rules\Engine\ConditionExpressionInterface; use Drupal\rules\Engine\ExecutionMetadataStateInterface; use Drupal\rules\Engine\ExecutionStateInterface; use Drupal\rules\Engine\ExpressionBase; use Drupal\rules\Engine\ExpressionInterface; -use Drupal\rules\Engine\IntegrityCheckTrait; +use Drupal\rules\Context\ContextHandlerIntegrityTrait; use Drupal\rules\Engine\IntegrityViolationList; use Symfony\Component\DependencyInjection\ContainerInterface; @@ -34,13 +33,12 @@ */ class RulesCondition extends ExpressionBase implements ConditionExpressionInterface, ContainerFactoryPluginInterface { - use ContextHandlerTrait; - use IntegrityCheckTrait; + use ContextHandlerIntegrityTrait; /** * The condition manager used to instantiate the condition plugin. * - * @var \Drupal\Core\Condition\ConditionManager + * @var \Drupal\rules\Core\ConditionManager */ protected $conditionManager; @@ -55,7 +53,7 @@ class RulesCondition extends ExpressionBase implements ConditionExpressionInterf * The plugin ID for the plugin instance. * @param mixed $plugin_definition * The plugin implementation definition. - * @param \Drupal\Core\Condition\ConditionManager $condition_manager + * @param \Drupal\rules\Core\ConditionManager $condition_manager * The condition manager. * @param \Drupal\rules\Context\DataProcessorManager $processor_manager * The data processor plugin manager. @@ -112,21 +110,12 @@ public function executeWithState(ExecutionStateInterface $state) { 'negate' => $this->configuration['negate'], ]); - // We have to forward the context values from our configuration to the - // condition plugin. - $this->mapContext($condition, $state); - - $condition->refineContextdefinitions(); - - // Send the context values through configured data processors before - // evaluating the condition. - $this->processData($condition, $state); - + $this->prepareContext($condition, $state); $result = $condition->evaluate(); // Now that the condition has been executed it can provide additional // context which we will have to pass back in the evaluation state. - $this->mapProvidedContext($condition, $state); + $this->addProvidedContext($condition, $state); if ($this->isNegated()) { $result = !$result; @@ -138,22 +127,16 @@ public function executeWithState(ExecutionStateInterface $state) { /** * {@inheritdoc} */ - public function isNegated() { - return !empty($this->configuration['negate']); + public function negate($negate = TRUE) { + $this->configuration['negate'] = $negate; + return $this; } /** * {@inheritdoc} */ - public function getContextDefinitions() { - if (!isset($this->contextDefinitions)) { - // Pass up the context definitions from the condition plugin. - // @todo do not always create plugin instances here, the instance should - // be reused. Maybe that is what plugin bags are for? - $condition = $this->conditionManager->createInstance($this->configuration['condition_id']); - $this->contextDefinitions = $condition->getContextDefinitions(); - } - return $this->contextDefinitions; + public function isNegated() { + return !empty($this->configuration['negate']); } /** @@ -180,7 +163,7 @@ public function getFormHandler() { /** * {@inheritdoc} */ - public function checkIntegrity(ExecutionMetadataStateInterface $metadata_state) { + public function checkIntegrity(ExecutionMetadataStateInterface $metadata_state, $apply_assertions = TRUE) { $violation_list = new IntegrityViolationList(); if (empty($this->configuration['condition_id'])) { $violation_list->addViolationWithMessage($this->t('Condition plugin ID is missing'), $this->getUuid()); @@ -196,20 +179,31 @@ public function checkIntegrity(ExecutionMetadataStateInterface $metadata_state) $condition = $this->conditionManager->createInstance($this->configuration['condition_id'], [ 'negate' => $this->configuration['negate'], ]); - - return $this->doCheckIntegrity($condition, $metadata_state); + // Prepare and refine the context before checking integrity, such that any + // context definition changes are respected while checking. + $this->prepareContextWithMetadata($condition, $metadata_state); + $result = $this->checkContextConfigIntegrity($condition, $metadata_state); + $this->prepareExecutionMetadataState($metadata_state, NULL, $apply_assertions); + return $result; } /** * {@inheritdoc} */ - public function prepareExecutionMetadataState(ExecutionMetadataStateInterface $metadata_state, ExpressionInterface $until = NULL) { - $condition = $this->actionManager->createInstance($this->configuration['condition_id']); - $this->addProvidedVariablesToExecutionMetadataState($condition, $metadata_state); - if ($until) { - return FALSE; + public function prepareExecutionMetadataState(ExecutionMetadataStateInterface $metadata_state, ExpressionInterface $until = NULL, $apply_assertions = TRUE) { + if ($until && $this->getUuid() === $until->getUuid()) { + return TRUE; + } + $condition = $this->conditionManager->createInstance($this->configuration['condition_id'], [ + 'negate' => $this->configuration['negate'], + ]); + // Make sure to refine context first, such that possibly refined definitions + // of provided context are respected. + $this->prepareContextWithMetadata($condition, $metadata_state); + $this->addProvidedContextDefinitions($condition, $metadata_state); + if ($apply_assertions && !$this->isNegated()) { + $this->assertMetadata($condition, $metadata_state); } - return TRUE; } } diff --git a/src/Plugin/RulesExpression/RulesLoop.php b/src/Plugin/RulesExpression/RulesLoop.php index 6e1efe96..01b275d6 100644 --- a/src/Plugin/RulesExpression/RulesLoop.php +++ b/src/Plugin/RulesExpression/RulesLoop.php @@ -11,7 +11,6 @@ use Drupal\rules\Engine\ActionExpressionContainer; use Drupal\rules\Engine\ExecutionMetadataStateInterface; use Drupal\rules\Engine\ExecutionStateInterface; -use Drupal\rules\Engine\ExpressionInterface; use Drupal\rules\Engine\IntegrityViolationList; use Drupal\rules\Exception\RulesIntegrityException; @@ -25,14 +24,22 @@ */ class RulesLoop extends ActionExpressionContainer { + /** + * {@inheritdoc} + */ + public function defaultConfiguration() { + return [ + // Default to 'list_item' as variable name for the list item. + 'list_item' => 'list_item', + ]; + } + /** * {@inheritdoc} */ public function executeWithState(ExecutionStateInterface $state) { $list_data = $state->fetchDataByPropertyPath($this->configuration['list']); - // Use a configured list item variable name, otherwise fall back to just - // 'list_item' as variable name. - $list_item_name = isset($this->configuration['list_item']) ? $this->configuration['list_item'] : 'list_item'; + $list_item_name = $this->configuration['list_item']; foreach ($list_data as $item) { $state->setVariableData($list_item_name, $item); @@ -48,7 +55,7 @@ public function executeWithState(ExecutionStateInterface $state) { /** * {@inheritdoc} */ - public function checkIntegrity(ExecutionMetadataStateInterface $metadata_state) { + public function checkIntegrity(ExecutionMetadataStateInterface $metadata_state, $apply_assertions = TRUE) { $violation_list = new IntegrityViolationList(); if (empty($this->configuration['list'])) { @@ -75,63 +82,50 @@ public function checkIntegrity(ExecutionMetadataStateInterface $metadata_state) return $violation_list; } - if ($list_definition instanceof ListDataDefinitionInterface) { - $list_item_definition = $list_definition->getItemDefinition(); - $metadata_state->setDataDefinition($list_item_name, $list_item_definition); - - $violation_list = parent::checkIntegrity($metadata_state); - - // Remove the list item variable after the loop, it is out of scope now. - $metadata_state->removeDataDefinition($list_item_name); + if (!$list_definition instanceof ListDataDefinitionInterface) { + $violation_list->addViolationWithMessage($this->t('The data type of list variable %list is not a list.', [ + '%list' => $this->configuration['list'], + ])); return $violation_list; } - $violation_list->addViolationWithMessage($this->t('The data type of list variable %list is not a list.', [ - '%list' => $this->configuration['list'], - ])); + // So far all ok, so continue with checking integrity in contained actions. + // The parent implementation will take care of invoking pre/post traversal + // metadata state preparations. + $violation_list = parent::checkIntegrity($metadata_state, $apply_assertions); return $violation_list; } /** * {@inheritdoc} */ - public function prepareExecutionMetadataState(ExecutionMetadataStateInterface $metadata_state, ExpressionInterface $until = NULL) { - if ($until && $this->getUuid() === $until->getUuid()) { - return TRUE; - } + protected function allowsMetadataAssertions() { + // As the list can be empty, we cannot ensure child expressions are + // executed at all - thus no assertions can be added. + return FALSE; + } - $list_item_name = isset($this->configuration['list_item']) ? $this->configuration['list_item'] : 'list_item'; + /** + * {@inheritdoc} + */ + protected function prepareExecutionMetadataStateBeforeTraversal(ExecutionMetadataStateInterface $metadata_state) { try { $list_definition = $metadata_state->fetchDefinitionByPropertyPath($this->configuration['list']); $list_item_definition = $list_definition->getItemDefinition(); - $metadata_state->setDataDefinition($list_item_name, $list_item_definition); + $metadata_state->setDataDefinition($this->configuration['list_item'], $list_item_definition); } catch (RulesIntegrityException $e) { // Silently eat the exception: we just continue without adding the list // item definition to the state. } + } - if ($until) { - foreach ($this->actions as $action) { - if ($action->getUuid() === $until->getUuid()) { - return TRUE; - } - $found = $action->prepareExecutionMetadataState($metadata_state, $until); - if ($found) { - return TRUE; - } - } - // Remove the list item variable after the loop, it is out of scope now. - $metadata_state->removeDataDefinition($list_item_name); - return FALSE; - } - - foreach ($this->actions as $action) { - $action->prepareExecutionMetadataState($metadata_state); - } + /** + * {@inheritdoc} + */ + protected function prepareExecutionMetadataStateAfterTraversal(ExecutionMetadataStateInterface $metadata_state) { // Remove the list item variable after the loop, it is out of scope now. - $metadata_state->removeDataDefinition($list_item_name); - return TRUE; + $metadata_state->removeDataDefinition($this->configuration['list_item']); } } diff --git a/src/Plugin/RulesExpression/RulesOr.php b/src/Plugin/RulesExpression/RulesOr.php index bc740109..bacc639f 100644 --- a/src/Plugin/RulesExpression/RulesOr.php +++ b/src/Plugin/RulesExpression/RulesOr.php @@ -34,4 +34,13 @@ public function evaluate(ExecutionStateInterface $state) { return empty($this->conditions); } + /** + * {@inheritdoc} + */ + protected function allowsMetadataAssertions() { + // We cannot garantuee child expressions are executed, thus we cannot allow + // metadata assertions. + return FALSE; + } + } diff --git a/tests/modules/rules_test_default_component/config/install/rules.component.rules_test_default_component.yml b/tests/modules/rules_test_default_component/config/install/rules.component.rules_test_default_component.yml index e0c9ca2d..91bff11f 100644 --- a/tests/modules/rules_test_default_component/config/install/rules.component.rules_test_default_component.yml +++ b/tests/modules/rules_test_default_component/config/install/rules.component.rules_test_default_component.yml @@ -13,8 +13,11 @@ component: type: 'entity:user' label: User description: 'The user whose mail address to print.' - provided_context: - - concatenated + provided_context_definitions: + concatenated: + type: 'string' + label: Concatenated text + description: 'The concatenated text.' expression: id: rules_rule conditions: diff --git a/tests/src/Integration/Action/EntityCreateTest.php b/tests/src/Integration/Action/EntityCreateTest.php index cbb8a75f..41a435bf 100644 --- a/tests/src/Integration/Action/EntityCreateTest.php +++ b/tests/src/Integration/Action/EntityCreateTest.php @@ -9,8 +9,11 @@ use Drupal\Core\Entity\EntityStorageBase; use Drupal\Core\Field\BaseFieldDefinition; +use Drupal\Core\Field\TypedData\FieldItemDataDefinition; +use Drupal\Core\TypedData\DataDefinitionInterface; use Drupal\rules\Context\ContextDefinition; use Drupal\Tests\rules\Integration\RulesEntityIntegrationTestBase; +use Prophecy\Argument; /** * @coversDefaultClass \Drupal\rules\Plugin\RulesAction\EntityCreate @@ -43,23 +46,33 @@ public function setUp() { $bundle_field_definition_optional = $this->prophesize(BaseFieldDefinition::class); $bundle_field_definition_required = $this->prophesize(BaseFieldDefinition::class); + $property_definition = $this->prophesize(DataDefinitionInterface::class); + $property_definition->getDataType()->willReturn('string'); + + $item_definition = $this->prophesize(FieldItemDataDefinition::class); + $item_definition->getPropertyDefinition(Argument::any()) + ->willReturn($property_definition->reveal()); + $item_definition->getMainPropertyName()->willReturn('value'); + // The next methods are mocked because EntityCreateDeriver executes them, // and the mocked field definition is instantiated without the necessary // information. + $bundle_field_definition->getItemDefinition() + ->willReturn($item_definition->reveal()); $bundle_field_definition->getCardinality()->willReturn(1) ->shouldBeCalledTimes(1); - $bundle_field_definition->getType()->willReturn('string') - ->shouldBeCalledTimes(1); + $bundle_field_definition->getType()->willReturn('string'); $bundle_field_definition->getLabel()->willReturn('Bundle') ->shouldBeCalledTimes(1); $bundle_field_definition->getDescription() ->willReturn('Bundle mock description') ->shouldBeCalledTimes(1); + $bundle_field_definition_required->getItemDefinition() + ->willReturn($item_definition->reveal()); $bundle_field_definition_required->getCardinality()->willReturn(1) ->shouldBeCalledTimes(1); - $bundle_field_definition_required->getType()->willReturn('string') - ->shouldBeCalledTimes(1); + $bundle_field_definition_required->getType()->willReturn('string'); $bundle_field_definition_required->getLabel()->willReturn('Required field') ->shouldBeCalledTimes(1); $bundle_field_definition_required->getDescription() @@ -140,7 +153,7 @@ public function testRequiredContexts() { */ public function testRefiningContextDefinitions() { $this->action->setContextValue('bundle', 'bundle_test'); - $this->action->refineContextDefinitions(); + $this->action->refineContextDefinitions([]); $this->assertEquals( $this->action->getProvidedContextDefinition('entity') ->getDataType(), 'entity:test:bundle_test' diff --git a/tests/src/Integration/Action/EntityFetchByFieldTest.php b/tests/src/Integration/Action/EntityFetchByFieldTest.php index 4e0aa43f..e101b5ce 100644 --- a/tests/src/Integration/Action/EntityFetchByFieldTest.php +++ b/tests/src/Integration/Action/EntityFetchByFieldTest.php @@ -172,7 +172,7 @@ public function testActionExecutionProvidedContextEntityType() { */ public function testRefiningContextDefinitions() { $this->action->setContextValue('type', 'entity_test'); - $this->action->refineContextDefinitions(); + $this->action->refineContextDefinitions([]); $this->assertEquals( $this->action->getProvidedContextDefinition('entity_fetched') ->getDataType(), 'entity:entity_test' diff --git a/tests/src/Integration/Action/EntityFetchByIdTest.php b/tests/src/Integration/Action/EntityFetchByIdTest.php index bd040717..bf633e1a 100644 --- a/tests/src/Integration/Action/EntityFetchByIdTest.php +++ b/tests/src/Integration/Action/EntityFetchByIdTest.php @@ -74,7 +74,7 @@ public function testActionExecution() { */ public function testRefiningContextDefinitions() { $this->action->setContextValue('type', 'entity_test'); - $this->action->refineContextDefinitions(); + $this->action->refineContextDefinitions([]); $this->assertEquals( $this->action->getProvidedContextDefinition('entity_fetched') ->getDataType(), 'entity:entity_test' diff --git a/tests/src/Integration/Action/RulesComponentActionTest.php b/tests/src/Integration/Action/RulesComponentActionTest.php new file mode 100644 index 00000000..67a7f1c8 --- /dev/null +++ b/tests/src/Integration/Action/RulesComponentActionTest.php @@ -0,0 +1,198 @@ +rulesExpressionManager->createRule(); + + $rules_config = new RulesComponentConfig([ + 'id' => 'test_rule', + 'label' => 'Test rule', + ], 'rules_component'); + $rules_config->setExpression($rule); + + $this->prophesizeStorage([$rules_config]); + + $definition = $this->actionManager->getDefinition('rules_component:test_rule'); + $this->assertEquals('Components', $definition['category']); + $this->assertEquals('Rule: Test rule', (string) $definition['label']); + } + + /** + * Tests that the execution of the action invokes the Rules component. + */ + public function testExecute() { + // Set up a rules component that will just save an entity. + $nested_rule = $this->rulesExpressionManager->createRule(); + $nested_rule->addAction('rules_entity_save', ContextConfig::create() + ->map('entity', 'entity') + ); + + $rules_config = new RulesComponentConfig([ + 'id' => 'test_rule', + 'label' => 'Test rule', + ], 'rules_component'); + $rules_config->setExpression($nested_rule); + $rules_config->setContextDefinitions(['entity' => ContextDefinition::create('entity')]); + + $this->prophesizeStorage([$rules_config]); + + // Invoke the rules component in another rule. + $rule = $this->rulesExpressionManager->createRule(); + $rule->addAction('rules_component:test_rule', ContextConfig::create() + ->map('entity', 'entity') + ); + + // The call to save the entity means that the action was executed. + $entity = $this->prophesizeEntity(EntityInterface::class); + $entity->save()->shouldBeCalledTimes(1); + + RulesComponent::create($rule) + ->addContextDefinition('entity', ContextDefinition::create('entity')) + ->setContextValue('entity', $entity->reveal()) + ->execute(); + } + + /** + * Tests that context definitions are available on the derived action. + */ + public function testContextDefinitions() { + $rule = $this->rulesExpressionManager->createRule(); + $rule + ->addAction('rules_entity_save', ContextConfig::create() + ->map('entity', 'entity') + ) + ->addAction('rules_test_string', ContextConfig::create() + ->setValue('text', 'x') + ); + + $rules_config = new RulesComponentConfig([ + 'id' => 'test_rule', + 'label' => 'Test rule', + ], 'rules_component'); + $rules_config->setExpression($rule); + + $context_definitions = ['entity' => ContextDefinition::create('entity')]; + $rules_config->setContextDefinitions($context_definitions); + $provided_definitions = ['concatenated' => ContextDefinition::create('string')]; + $rules_config->setProvidedContextDefinitions($provided_definitions); + + $this->prophesizeStorage([$rules_config]); + + $definition = $this->actionManager->getDefinition('rules_component:test_rule'); + $this->assertEquals($context_definitions, $definition['context']); + $this->assertEquals($provided_definitions, $definition['provides']); + } + + /** + * Tests that a rules component in an action can also provide variables. + */ + public function testExecutionProvidedVariables() { + // Create a rule that produces a provided string variable. + $nested_rule = $this->rulesExpressionManager->createRule(); + $nested_rule->addAction('rules_test_string', ContextConfig::create() + ->setValue('text', 'x') + ); + + $rules_config = new RulesComponentConfig([ + 'id' => 'test_rule', + 'label' => 'Test rule', + ], 'rules_component'); + $rules_config->setExpression($nested_rule); + $rules_config->setProvidedContextDefinitions(['concatenated' => ContextDefinition::create('string')]); + + $this->prophesizeStorage([$rules_config]); + + // Invoke the rules component in another rule. + $rule = $this->rulesExpressionManager->createRule(); + $rule->addAction('rules_component:test_rule'); + + $result = RulesComponent::create($rule) + ->provideContext('concatenated') + ->execute(); + + $this->assertEquals('xx', $result['concatenated']); + } + + /** + * Tests that auto saving is only triggered once with nested components. + */ + public function testAutosaveOnlyOnce() { + $entity = $this->prophesizeEntity(EntityInterface::class); + + $nested_rule = $this->rulesExpressionManager->createRule(); + $nested_rule->addAction('rules_entity_save', ContextConfig::create() + ->map('entity', 'entity') + ); + + $rules_config = new RulesComponentConfig([ + 'id' => 'test_rule', + 'label' => 'Test rule', + ], 'rules_component'); + $rules_config->setExpression($nested_rule); + $rules_config->setContextDefinitions(['entity' => ContextDefinition::create('entity')]); + + $this->prophesizeStorage([$rules_config]); + + // Create a rule with a nested rule. Overall there are 2 actions to set the + // entity then. + $rule = $this->rulesExpressionManager->createRule(); + $rule->addAction('rules_component:test_rule', ContextConfig::create() + ->map('entity', 'entity') + ); + $rule->addAction('rules_entity_save', ContextConfig::create() + ->map('entity', 'entity') + ); + + // Auto-saving should only be triggered once on the entity. + $entity->save()->shouldBeCalledTimes(1); + + RulesComponent::create($rule) + ->addContextDefinition('entity', ContextDefinition::create('entity')) + ->setContextValue('entity', $entity->reveal()) + ->execute(); + } + + /** + * Prepares a mocked entity storage that returns the provided Rules configs. + * + * @param RulesComponentConfig[] $rules_configs + * The Rules componentn config entities that should be returned. + */ + protected function prophesizeStorage($rules_configs) { + $storage = $this->prophesize(ConfigEntityStorageInterface::class); + $keyed_configs = []; + + foreach ($rules_configs as $rules_config) { + $keyed_configs[$rules_config->id()] = $rules_config; + $storage->load($rules_config->id())->willReturn($rules_config); + } + + $storage->loadMultiple(NULL)->willReturn($keyed_configs); + $this->entityTypeManager->getStorage('rules_component')->willReturn($storage->reveal()); + } + +} diff --git a/tests/src/Integration/Action/VariableAddTest.php b/tests/src/Integration/Action/VariableAddTest.php index f96c55ec..32c71825 100644 --- a/tests/src/Integration/Action/VariableAddTest.php +++ b/tests/src/Integration/Action/VariableAddTest.php @@ -34,10 +34,12 @@ public function testExecute() { $action = $this->actionManager->createInstance('rules_variable_add'); $action->setContextValue('type', 'string'); $action->setContextValue('value', $variable); + $action->refineContextDefinitions([]); $action->execute(); $result = $action->getProvidedContext('variable_added'); $this->assertEquals($variable, $result->getContextValue()); + $this->assertEquals('string', $result->getContextDefinition()->getDataType()); } } diff --git a/tests/src/Integration/Condition/DataComparisonTest.php b/tests/src/Integration/Condition/DataComparisonTest.php index 6310ba0d..faf1711f 100644 --- a/tests/src/Integration/Condition/DataComparisonTest.php +++ b/tests/src/Integration/Condition/DataComparisonTest.php @@ -7,6 +7,7 @@ namespace Drupal\Tests\rules\Integration\Condition; +use Drupal\Core\TypedData\DataDefinition; use Drupal\Tests\rules\Integration\RulesIntegrationTestBase; /** @@ -55,76 +56,76 @@ public function testConditionEvaluationOperatorEquals() { // is '==', TRUE is returned. $this->condition ->setContextValue('data', 'Llama') - ->setContextValue('operator', '==') + ->setContextValue('operation', '==') ->setContextValue('value', 'Llama'); $this->assertTrue($this->condition->evaluate()); // Test that when the data string does not equal the value string and the - // operator is '==', FALSE is returned. + // operation is '==', FALSE is returned. $this->condition ->setContextValue('data', 'Kitten') - ->setContextValue('operator', '==') + ->setContextValue('operation', '==') ->setContextValue('value', 'Llama'); $this->assertFalse($this->condition->evaluate()); - // Test that when both data and value are false booleans and the operator + // Test that when both data and value are false booleans and the operation // is '==', TRUE is returned. $this->condition ->setContextValue('data', FALSE) - ->setContextValue('operator', '==') + ->setContextValue('operation', '==') ->setContextValue('value', FALSE); $this->assertTrue($this->condition->evaluate()); // Test that when a boolean data does not equal a boolean value - // and the operator is '==', FALSE is returned. + // and the operation is '==', FALSE is returned. $this->condition ->setContextValue('data', TRUE) - ->setContextValue('operator', '==') + ->setContextValue('operation', '==') ->setContextValue('value', FALSE); $this->assertFalse($this->condition->evaluate()); } /** - * Tests evaluating the condition with the "contains" operator. + * Tests evaluating the condition with the "contains" operation. * * @covers ::evaluate */ public function testConditionEvaluationOperatorContains() { // Test that when the data string contains the value string, and the - // operator is 'CONTAINS', TRUE is returned. + // operation is 'CONTAINS', TRUE is returned. $this->condition ->setContextValue('data', 'Big Llama') - ->setContextValue('operator', 'contains') + ->setContextValue('operation', 'contains') ->setContextValue('value', 'Llama'); $this->assertTrue($this->condition->evaluate()); // Test that when the data string does not contain the value string, and - // the operator is 'contains', TRUE is returned. + // the operation is 'contains', TRUE is returned. $this->condition ->setContextValue('data', 'Big Kitten') - ->setContextValue('operator', 'contains') + ->setContextValue('operation', 'contains') ->setContextValue('value', 'Big Kitten'); $this->assertTrue($this->condition->evaluate()); - // Test that when a data array contains the value string, and the operator + // Test that when a data array contains the value string, and the operation // is 'CONTAINS', TRUE is returned. $this->condition ->setContextValue('data', ['Llama', 'Kitten']) - ->setContextValue('operator', 'contains') + ->setContextValue('operation', 'contains') ->setContextValue('value', 'Llama'); $this->assertTrue($this->condition->evaluate()); // Test that when a data array does not contain the value array, and the - // operator is 'CONTAINS', TRUE is returned. + // operation is 'CONTAINS', TRUE is returned. $this->condition ->setContextValue('data', ['Kitten']) - ->setContextValue('operator', 'contains') + ->setContextValue('operation', 'contains') ->setContextValue('value', ['Llama']); $this->assertFalse($this->condition->evaluate()); } /** - * Tests evaluating the condition with the "IN" operator. + * Tests evaluating the condition with the "IN" operation. * * @covers ::evaluate */ @@ -132,61 +133,61 @@ public function testConditionEvaluationOperatorIn() { // Test that when the data string is 'IN' the value array, TRUE is returned. $this->condition ->setContextValue('data', 'Llama') - ->setContextValue('operator', 'IN') + ->setContextValue('operation', 'IN') ->setContextValue('value', ['Llama', 'Kitten']); $this->assertTrue($this->condition->evaluate()); - // Test that when the data array is not in the value array, and the operator - // is 'IN', FALSE is returned. + // Test that when the data array is not in the value array, and the + // operation is 'IN', FALSE is returned. $this->condition ->setContextValue('data', ['Llama']) - ->setContextValue('operator', 'IN') + ->setContextValue('operation', 'IN') ->setContextValue('value', ['Kitten']); $this->assertFalse($this->condition->evaluate()); } /** - * Tests evaluating the condition with the "is less than" operator. + * Tests evaluating the condition with the "is less than" operation. * * @covers ::evaluate */ public function testConditionEvaluationOperatorLessThan() { - // Test that when data is less than value and operator is '<', + // Test that when data is less than value and operation is '<', // TRUE is returned. $this->condition ->setContextValue('data', 1) - ->setContextValue('operator', '<') + ->setContextValue('operation', '<') ->setContextValue('value', 2); $this->assertTrue($this->condition->evaluate()); - // Test that when data is greater than value and operator is '<', + // Test that when data is greater than value and operation is '<', // FALSE is returned. $this->condition ->setContextValue('data', 2) - ->setContextValue('operator', '<') + ->setContextValue('operation', '<') ->setContextValue('value', 1); $this->assertFalse($this->condition->evaluate()); } /** - * Tests evaluating the condition with the "is greater than" operator. + * Tests evaluating the condition with the "is greater than" operation. * * @covers ::evaluate */ public function testConditionEvaluationOperatorGreaterThan() { - // Test that when data is greater than value and operator is '>', + // Test that when data is greater than value and operation is '>', // TRUE is returned. $this->condition ->setContextValue('data', 2) - ->setContextValue('operator', '>') + ->setContextValue('operation', '>') ->setContextValue('value', 1); $this->assertTrue($this->condition->evaluate()); - // Test that when data is less than value and operator is '>', + // Test that when data is less than value and operation is '>', // FALSE is returned. $this->condition ->setContextValue('data', 1) - ->setContextValue('operator', '>') + ->setContextValue('operation', '>') ->setContextValue('value', 2); $this->assertFalse($this->condition->evaluate()); } @@ -200,4 +201,23 @@ public function testSummary() { $this->assertEquals('Data comparison', $this->condition->summary()); } + /** + * @covers ::refineContextDefinitions + */ + public function testRefineContextDefinitions() { + // When a string is selected for comparison, the value must be string also. + $this->condition->refineContextDefinitions([ + 'data' => DataDefinition::create('string'), + ]); + $this->assertEquals('string', $this->condition->getContextDefinition('value')->getDataType()); + + // IN operation requires a list of strings as value. + $this->condition->setContextValue('operation', 'IN'); + $this->condition->refineContextDefinitions([ + 'data' => DataDefinition::create('string'), + ]); + $this->assertEquals('string', $this->condition->getContextDefinition('value')->getDataType()); + $this->assertTrue($this->condition->getContextDefinition('value')->isMultiple()); + } + } diff --git a/tests/src/Integration/Engine/IntegrityCheckTest.php b/tests/src/Integration/Engine/IntegrityCheckTest.php index 4466834a..ebb731ae 100644 --- a/tests/src/Integration/Engine/IntegrityCheckTest.php +++ b/tests/src/Integration/Engine/IntegrityCheckTest.php @@ -166,7 +166,7 @@ public function testInvalidProvidedName() { } /** - * Tests the input restrction on contexts. + * Tests the input restriction on contexts. */ public function testInputRestriction() { $rule = $this->rulesExpressionManager->createRule(); @@ -235,7 +235,7 @@ public function testPrimitiveTypeViolation() { ->checkIntegrity(); $this->assertEquals(1, iterator_count($violation_list)); $this->assertEquals( - 'Expected a primitive data type for context Text to compare but got a list data type instead.', + 'Expected a string data type for context Text to compare but got a list data type instead.', (string) $violation_list[0]->getMessage() ); $this->assertEquals($condition->getUuid(), $violation_list[0]->getUuid()); @@ -287,7 +287,7 @@ public function testComplexTypeViolation() { ->checkIntegrity(); $this->assertEquals(1, iterator_count($violation_list)); $this->assertEquals( - 'Expected a complex data type for context Node but got a list data type instead.', + 'Expected a entity:node data type for context Node but got a list data type instead.', (string) $violation_list[0]->getMessage() ); $this->assertEquals($condition->getUuid(), $violation_list[0]->getUuid()); @@ -299,7 +299,7 @@ public function testComplexTypeViolation() { public function testMissingRequiredContext() { $rule = $this->rulesExpressionManager->createRule(); - // The condition is completely unconfigured, missing 2 required contexts. + // The condition is completely un-configured, missing 2 required contexts. $condition = $this->rulesExpressionManager->createCondition('rules_node_is_of_type'); $rule->addExpressionObject($condition); @@ -339,4 +339,87 @@ public function testNestedExpressionUuids() { $this->assertEquals($action->getUuid(), $violation_list[0]->getUuid()); } + /** + * Tests using provided variables in sub-sequent actions passes checks. + */ + public function testUsingProvidedVariables() { + $rule = $this->rulesExpressionManager->createRule(); + + $rule->addAction('rules_variable_add', ContextConfig::create() + ->setValue('type', 'any') + ->setValue('value', 'foo') + ); + $rule->addAction('rules_variable_add', ContextConfig::create() + ->setValue('type', 'any') + ->map('value', 'variable_added') + ); + + $violation_list = RulesComponent::create($rule) + ->checkIntegrity(); + $this->assertEquals(0, iterator_count($violation_list)); + } + + /** + * Tests that refined context is respected when checking context. + */ + public function testRefinedContextViolation() { + $rule = $this->rulesExpressionManager->createRule(); + $rule->addAction('rules_variable_add', ContextConfig::create() + ->setValue('type', 'integer') + ->map('value', 'text') + ); + + $violation_list = RulesComponent::create($rule) + ->addContextDefinition('text', ContextDefinition::create('string')) + ->checkIntegrity(); + $this->assertEquals(1, iterator_count($violation_list)); + } + + /** + * Tests context can be refined based upon mapped context. + */ + public function testRefiningContextBasedonMappedContext() { + // DataComparision condition refines context based on selected data. Thus + // it for the test and ensure checking integrity passes when the comparison + // value is of a compatible type and fails else. + $rule = $this->rulesExpressionManager->createRule(); + $rule->addCondition('rules_data_comparison', ContextConfig::create() + ->map('data', 'text') + ->map('value', 'text2') + ); + + $violation_list = RulesComponent::create($rule) + ->addContextDefinition('text', ContextDefinition::create('string')) + ->addContextDefinition('text2', ContextDefinition::create('string')) + ->checkIntegrity(); + $this->assertEquals(0, iterator_count($violation_list)); + + $violation_list = RulesComponent::create($rule) + ->addContextDefinition('text', ContextDefinition::create('string')) + ->addContextDefinition('text2', ContextDefinition::create('integer')) + ->checkIntegrity(); + $this->assertEquals(1, iterator_count($violation_list)); + } + + /** + * Tests using provided variables with refined context. + */ + public function testUsingRefinedProvidedVariables() { + $rule = $this->rulesExpressionManager->createRule(); + + $rule->addAction('rules_variable_add', ContextConfig::create() + ->setValue('type', 'string') + ->setValue('value', 'foo') + ); + $rule->addAction('rules_system_message', ContextConfig::create() + ->map('message', 'variable_added') + ->setValue('type', 'status') + ); + // The message action requires a string, thus if the context is not refined + // it will end up as "any" and integrity check would fail. + $violation_list = RulesComponent::create($rule) + ->checkIntegrity(); + $this->assertEquals(0, iterator_count($violation_list)); + } + } diff --git a/tests/src/Integration/Engine/PrepareExecutionMetadataStateTest.php b/tests/src/Integration/Engine/PrepareExecutionMetadataStateTest.php index a415c67d..de34d5bb 100644 --- a/tests/src/Integration/Engine/PrepareExecutionMetadataStateTest.php +++ b/tests/src/Integration/Engine/PrepareExecutionMetadataStateTest.php @@ -34,7 +34,7 @@ public function testAddingVariable() { $state = ExecutionMetadataState::create(); $found = $rule->prepareExecutionMetadataState($state); $this->assertTrue($state->hasDataDefinition('result')); - $this->assertTrue($found); + $this->assertNull($found); } /** @@ -113,7 +113,7 @@ public function testPrepareAfterLoop() { $found = $rule->prepareExecutionMetadataState($state); $this->assertFalse($state->hasDataDefinition('list_item')); - $this->assertTrue($found); + $this->assertNull($found); } } diff --git a/tests/src/Integration/RulesIntegrationTestBase.php b/tests/src/Integration/RulesIntegrationTestBase.php index dccd21c7..b31b72ee 100644 --- a/tests/src/Integration/RulesIntegrationTestBase.php +++ b/tests/src/Integration/RulesIntegrationTestBase.php @@ -11,6 +11,7 @@ use Drupal\Core\Cache\NullBackend; use Drupal\Core\DependencyInjection\ClassResolverInterface; use Drupal\Core\DependencyInjection\ContainerBuilder; +use Drupal\Core\Config\Entity\ConfigEntityStorageInterface; use Drupal\Core\Entity\EntityFieldManagerInterface; use Drupal\Core\Entity\EntityManagerInterface; use Drupal\Core\Entity\EntityTypeBundleInfoInterface; @@ -204,6 +205,11 @@ public function setUp() { $this->entityTypeManager = $this->prophesize(EntityTypeManagerInterface::class); $this->entityTypeManager->getDefinitions()->willReturn([]); + // Setup a rules_component storage mock which returns nothing by default. + $storage = $this->prophesize(ConfigEntityStorageInterface::class); + $storage->loadMultiple(NULL)->willReturn([]); + $this->entityTypeManager->getStorage('rules_component')->willReturn($storage->reveal()); + $this->entityFieldManager = $this->prophesize(EntityFieldManagerInterface::class); $this->entityFieldManager->getBaseFieldDefinitions()->willReturn([]); diff --git a/tests/src/Kernel/ConfigurableEventHandlerTest.php b/tests/src/Kernel/ConfigurableEventHandlerTest.php index 28c2bfa3..57eb1534 100644 --- a/tests/src/Kernel/ConfigurableEventHandlerTest.php +++ b/tests/src/Kernel/ConfigurableEventHandlerTest.php @@ -93,9 +93,11 @@ public function testConfigurableEventHandler() { ); $config_entity1 = $this->storage->create([ 'id' => 'test_rule1', - 'expression_id' => 'rules_rule', - 'event' => 'rules_entity_presave:node--page', - ])->setExpression($rule1); + ]); + $config_entity1->set('events', [ + ['event_name' => 'rules_entity_presave:node--page'], + ]); + $config_entity1->set('expression', $rule1->getConfiguration()); $config_entity1->save(); // Create rule2 with the 'rules_entity_presave:node' event. @@ -106,9 +108,11 @@ public function testConfigurableEventHandler() { ); $config_entity2 = $this->storage->create([ 'id' => 'test_rule2', - 'expression_id' => 'rules_rule', - 'event' => 'rules_entity_presave:node', - ])->setExpression($rule2); + ]); + $config_entity2->set('events', [ + ['event_name' => 'rules_entity_presave:node'], + ]); + $config_entity2->set('expression', $rule2->getConfiguration()); $config_entity2->save(); // The logger instance has changed, refresh it. diff --git a/tests/src/Kernel/CoreIntegrationTest.php b/tests/src/Kernel/CoreIntegrationTest.php index 64293ab3..bb9c3365 100644 --- a/tests/src/Kernel/CoreIntegrationTest.php +++ b/tests/src/Kernel/CoreIntegrationTest.php @@ -7,9 +7,11 @@ namespace Drupal\Tests\rules\Kernel; +use Drupal\node\Entity\Node; use Drupal\rules\Context\ContextConfig; use Drupal\rules\Context\ContextDefinition; use Drupal\rules\Engine\RulesComponent; +use Drupal\rules\Entity\RulesComponentConfig; use Drupal\user\Entity\User; /** @@ -244,6 +246,45 @@ public function testDataSetEntities() { $this->assertNotNull($node->id(), 'Node ID is set, which means that the node has been auto-saved.'); } + /** + * Tests that auto saving in a component executed as action works. + */ + public function testComponentActionAutoSave() { + $entity_type_manager = $this->container->get('entity_type.manager'); + $entity_type_manager->getStorage('node_type') + ->create(['type' => 'page']) + ->save(); + + $nested_rule = $this->expressionManager->createRule(); + // Create a node entity with the action. + $nested_rule->addAction('rules_entity_create:node', ContextConfig::create() + ->setValue('type', 'page') + ); + // Set the title of the new node so that it is marked for auto-saving. + $nested_rule->addAction('rules_data_set', ContextConfig::create() + ->map('data', 'entity.title') + ->setValue('value', 'new title') + ); + + $rules_config = new RulesComponentConfig([ + 'id' => 'test_rule', + 'label' => 'Test rule', + ], 'rules_component'); + $rules_config->setExpression($nested_rule); + $rules_config->save(); + + // Invoke the rules component in another rule. + $rule = $this->expressionManager->createRule(); + $rule->addAction('rules_component:test_rule'); + + RulesComponent::create($rule)->execute(); + + $nodes = Node::loadMultiple(); + $node = reset($nodes); + $this->assertEquals('new title', $node->getTitle()); + $this->assertNotNull($node->id(), 'Node ID is set, which means that the node has been auto-saved.'); + } + /** * Tests using global context. */ diff --git a/tests/src/Kernel/Engine/MetadataAssertionTest.php b/tests/src/Kernel/Engine/MetadataAssertionTest.php new file mode 100644 index 00000000..6cea3a66 --- /dev/null +++ b/tests/src/Kernel/Engine/MetadataAssertionTest.php @@ -0,0 +1,171 @@ +installSchema('system', ['sequences']); + $this->installEntitySchema('user'); + $this->installEntitySchema('node'); + $this->installConfig(['field']); + + $entity_type_manager = $this->container->get('entity_type.manager'); + $entity_type_manager->getStorage('node_type') + ->create(['type' => 'page']) + ->save(); + + FieldStorageConfig::create([ + 'field_name' => 'field_text', + 'type' => 'string', + 'entity_type' => 'node', + 'cardinality' => 1, + ])->save(); + FieldConfig::create([ + 'field_name' => 'field_text', + 'entity_type' => 'node', + 'bundle' => 'page', + ])->save(); + } + + /** + * Tests asserting metadata using the EntityIfOfBundle condition. + */ + public function testAssertingEntityBundle() { + // When trying to use the field_text field without knowledge of the bundle, + // the field is not available. + $rule = $this->expressionManager->createRule(); + $rule->addAction('rules_system_message', ContextConfig::create() + ->map('message', 'node.field_text.value') + ->setValue('type', 'status') + ); + $violation_list = RulesComponent::create($rule) + ->addContextDefinition('node', ContextDefinition::create('entity:node')) + ->checkIntegrity(); + $this->assertEquals(1, iterator_count($violation_list)); + $this->assertEquals( + 'Data selector %selector for context %context_name is invalid. @message', + $violation_list->get(0)->getMessage()->getUntranslatedString() + ); + + // Now add the EntityIsOfBundle condition and try again. + $rule->addCondition('rules_entity_is_of_bundle', ContextConfig::create() + ->map('entity', 'node') + ->setValue('type', 'node') + ->setValue('bundle', 'page') + ); + $violation_list = RulesComponent::create($rule) + ->addContextDefinition('node', ContextDefinition::create('entity:node')) + ->checkIntegrity(); + $this->assertEquals(0, iterator_count($violation_list)); + } + + /** + * Tests asserted metadata is handled correctly in OR and AND containers. + */ + public function testAssertingWithLogicalOperations() { + // Add an nested AND and make sure it keeps working. + $rule = $this->expressionManager->createRule(); + $and = $this->expressionManager->createAnd(); + $and->addCondition('rules_entity_is_of_bundle', ContextConfig::create() + ->map('entity', 'node') + ->setValue('type', 'node') + ->setValue('bundle', 'page') + ); + $rule->addExpressionObject($and); + $rule->addAction('rules_system_message', ContextConfig::create() + ->map('message', 'node.field_text.value') + ->setValue('type', 'status') + ); + $violation_list = RulesComponent::create($rule) + ->addContextDefinition('node', ContextDefinition::create('entity:node')) + ->checkIntegrity(); + $this->assertEquals(0, iterator_count($violation_list)); + + // Add an nested OR and make sure it is ignored. + $rule = $this->expressionManager->createRule(); + $or = $this->expressionManager->createOr(); + $or->addCondition('rules_entity_is_of_bundle', ContextConfig::create() + ->map('entity', 'node') + ->setValue('type', 'node') + ->setValue('bundle', 'page') + ); + $rule->addExpressionObject($or); + $rule->addAction('rules_system_message', ContextConfig::create() + ->map('message', 'node.field_text.value') + ->setValue('type', 'status') + ); + $violation_list = RulesComponent::create($rule) + ->addContextDefinition('node', ContextDefinition::create('entity:node')) + ->checkIntegrity(); + $this->assertEquals(1, iterator_count($violation_list)); + } + + /** + * Tests asserted metadata of negated conditions is ignored. + */ + public function testAssertingOfNegatedConditions() { + // Negate the condition only and make sure it is ignored. + $rule = $this->expressionManager->createRule(); + $rule->addCondition('rules_entity_is_of_bundle', ContextConfig::create() + ->map('entity', 'node') + ->setValue('type', 'node') + ->setValue('bundle', 'page') + )->negate(TRUE); + $rule->addAction('rules_system_message', ContextConfig::create() + ->map('message', 'node.field_text.value') + ->setValue('type', 'status') + ); + $violation_list = RulesComponent::create($rule) + ->addContextDefinition('node', ContextDefinition::create('entity:node')) + ->checkIntegrity(); + $this->assertEquals(1, iterator_count($violation_list)); + + // Add an negated AND and make sure it is ignored. + $rule = $this->expressionManager->createRule(); + $and = $this->expressionManager->createAnd(); + $and->addCondition('rules_entity_is_of_bundle', ContextConfig::create() + ->map('entity', 'node') + ->setValue('type', 'node') + ->setValue('bundle', 'page') + ); + $and->negate(TRUE); + $rule->addExpressionObject($and); + $rule->addAction('rules_system_message', ContextConfig::create() + ->map('message', 'node.field_text.value') + ->setValue('type', 'status') + ); + $violation_list = RulesComponent::create($rule) + ->addContextDefinition('node', ContextDefinition::create('entity:node')) + ->checkIntegrity(); + $this->assertEquals(1, iterator_count($violation_list)); + } + +} diff --git a/tests/src/Kernel/EventIntegrationTest.php b/tests/src/Kernel/EventIntegrationTest.php index d1dff4f4..7bc7e5d3 100644 --- a/tests/src/Kernel/EventIntegrationTest.php +++ b/tests/src/Kernel/EventIntegrationTest.php @@ -53,7 +53,7 @@ public function testUserLoginEvent() { $config_entity = $this->storage->create([ 'id' => 'test_rule', - 'event' => 'rules_user_login', + 'events' => [['event_name' => 'rules_user_login']], 'expression' => $rule->getConfiguration(), ]); $config_entity->save(); @@ -79,7 +79,7 @@ public function testUserLogoutEvent() { $config_entity = $this->storage->create([ 'id' => 'test_rule', - 'event' => 'rules_user_logout', + 'events' => [['event_name' => 'rules_user_logout']], 'expression' => $rule->getConfiguration(), ]); $config_entity->save(); @@ -105,7 +105,7 @@ public function testCronEvent() { $config_entity = $this->storage->create([ 'id' => 'test_rule', - 'event' => 'rules_system_cron', + 'events' => [['event_name' => 'rules_system_cron']], 'expression' => $rule->getConfiguration(), ]); $config_entity->save(); @@ -130,7 +130,7 @@ public function testSystemLoggerEvent() { $config_entity = $this->storage->create([ 'id' => 'test_rule', - 'event' => 'rules_system_logger_event', + 'events' => [['event_name' => 'rules_system_logger_event']], 'expression' => $rule->getConfiguration(), ]); $config_entity->save(); @@ -156,7 +156,7 @@ public function testInitEvent() { $config_entity = $this->storage->create([ 'id' => 'test_rule', - 'event' => KernelEvents::REQUEST, + 'events' => [['event_name' => KernelEvents::REQUEST]], 'expression' => $rule->getConfiguration(), ]); $config_entity->save(); @@ -180,4 +180,37 @@ public function testInitEvent() { $this->assertRulesLogEntryExists('action called'); } + /** + * Test that rules config supports multiple events. + */ + public function testMultipleEvents() { + $rule = $this->expressionManager->createRule(); + $rule->addCondition('rules_test_true'); + $rule->addAction('rules_test_log'); + + $config_entity = $this->storage->create([ + 'id' => 'test_rule', + ]); + $config_entity->set('events', [ + ['event_name' => 'rules_user_login'], + ['event_name' => 'rules_user_logout'], + ]); + $config_entity->set('expression', $rule->getConfiguration()); + $config_entity->save(); + + // The logger instance has changed, refresh it. + $this->logger = $this->container->get('logger.channel.rules'); + + $account = User::create(['name' => 'test_user']); + // Invoke the hook manually which should trigger the rules_user_login event. + rules_user_login($account); + // Invoke the hook manually which should trigger the rules_user_logout + // event. + rules_user_logout($account); + + // Test that the action in the rule logged something. + $this->assertRulesLogEntryExists('action called'); + $this->assertRulesLogEntryExists('action called', 1); + } + } diff --git a/tests/src/Unit/ContextHandlerTraitTest.php b/tests/src/Unit/ContextHandlerTraitTest.php index 442c6c36..f45f50d4 100644 --- a/tests/src/Unit/ContextHandlerTraitTest.php +++ b/tests/src/Unit/ContextHandlerTraitTest.php @@ -22,7 +22,7 @@ class ContextHandlerTraitTest extends RulesUnitTestBase { /** * Tests that a missing required context triggers an exception. * - * @covers ::mapContext + * @covers ::prepareContext * * @expectedException \Drupal\rules\Exception\RulesEvaluationException * @@ -43,13 +43,16 @@ public function testMissingContext() { $plugin->getContextDefinitions() ->willReturn(['test' => $context_definition->reveal()]) ->shouldBeCalled(1); + $plugin->getContextValue('test') + ->willReturn(NULL) + ->shouldBeCalled(1); $plugin->getPluginId()->willReturn('testplugin')->shouldBeCalledTimes(1); $state = $this->prophesize(ExecutionStateInterface::class); // Make the 'mapContext' method visible. $reflection = new \ReflectionClass($trait); - $method = $reflection->getMethod('mapContext'); + $method = $reflection->getMethod('prepareContext'); $method->setAccessible(TRUE); $method->invokeArgs($trait, [$plugin->reveal(), $state->reveal()]); } diff --git a/tests/src/Unit/RulesConditionTest.php b/tests/src/Unit/RulesConditionTest.php index 262b8fb6..ec1d1533 100644 --- a/tests/src/Unit/RulesConditionTest.php +++ b/tests/src/Unit/RulesConditionTest.php @@ -7,7 +7,7 @@ namespace Drupal\Tests\rules\Unit; -use Drupal\Core\Condition\ConditionManager; +use Drupal\rules\Core\ConditionManager; use Drupal\Core\Plugin\Context\ContextDefinitionInterface; use Drupal\rules\Context\DataProcessorInterface; use Drupal\rules\Context\ContextConfig; @@ -28,7 +28,7 @@ class RulesConditionTest extends UnitTestCase { /** * The mocked condition manager. * - * @var \Drupal\Core\Condition\ConditionManager|\Prophecy\Prophecy\ProphecyInterface + * @var \Drupal\rules\Core\ConditionManager|\Prophecy\Prophecy\ProphecyInterface */ protected $conditionManager; @@ -73,22 +73,6 @@ public function setUp() { $this->conditionManager->reveal(), $this->processorManager->reveal()); } - /** - * Tests that context definitions are retrieved form the plugin. - */ - public function testContextDefinitions() { - $context_definition = $this->prophesize(ContextDefinitionInterface::class); - $this->trueCondition->getContextDefinitions() - ->willReturn(['test' => $context_definition->reveal()]) - ->shouldBeCalledTimes(1); - - $this->conditionManager->createInstance('test_condition') - ->willReturn($this->trueCondition->reveal()) - ->shouldBeCalledTimes(1); - - $this->assertSame($this->conditionExpression->getContextDefinitions(), ['test' => $context_definition->reveal()]); - } - /** * Tests that context values get data processed with processor mappings. */ @@ -117,11 +101,11 @@ public function testDataProcessor() { // Mock some original old value that will be replaced by the data processor. $this->trueCondition->getContextValue('test') ->willReturn('old_value') - ->shouldBeCalledTimes(1); + ->shouldBeCalled(); // The outcome of the data processor needs to get set on the condition. $this->trueCondition->setContextValue('test', 'new_value')->shouldBeCalledTimes(1); - $this->trueCondition->refineContextDefinitions()->shouldBeCalledTimes(1); + $this->trueCondition->refineContextDefinitions([])->shouldBeCalledTimes(1); $data_processor = $this->prophesize(DataProcessorInterface::class); $data_processor->process('old_value', Argument::any()) @@ -146,7 +130,7 @@ public function testDataProcessor() { */ public function testNegation() { $this->trueCondition->getContextDefinitions()->willReturn([]); - $this->trueCondition->refineContextDefinitions()->shouldBeCalledTimes(1); + $this->trueCondition->refineContextDefinitions([])->shouldBeCalledTimes(1); $this->trueCondition->getProvidedContextDefinitions() ->willReturn([]) ->shouldBeCalledTimes(1);