diff --git a/Actions/ReadData.php b/Actions/ReadData.php index 727f5fae6..e6e71968b 100644 --- a/Actions/ReadData.php +++ b/Actions/ReadData.php @@ -1,6 +1,8 @@ getExpectedColumns(); + + try { + $validator->validateTaskColumns($expectedColumns); + } catch (ActionTaskInvalidException $exception) { + $task = $validator->getTask(); + if(!$task->hasInputData()) { + throw $exception; + } + + // We ignore unexpected columns IF they are system columns. + $inputData = $task->getInputData(); + foreach ($exception->getIssue(ActionTaskInvalidException::ISSUE_UNEXPECTED_COLUMN) as $badColumn) { + $col = $inputData->getColumns()->get($badColumn); + if( + $col !== null && + $col->isAttribute() && + $col->getAttribute()->isSystem() + ) { + $inputData->getColumns()->removeByKey($badColumn); + } + } + } + } + /** * * {@inheritDoc} diff --git a/Actions/SaveData.php b/Actions/SaveData.php index ae7ae67a7..37c1f72a2 100644 --- a/Actions/SaveData.php +++ b/Actions/SaveData.php @@ -1,6 +1,7 @@ setInputRowsMax(null); } + /** + * @inheritDoc + */ + protected function validateApplicability(ActionInputValidator $validator): void + { + parent::validateApplicability($validator); + + $expectedColumns = $validator->getExpectedColumns(); + $validator->validateTaskColumns($expectedColumns); + } + /** * * {@inheritDoc} diff --git a/CommonLogic/AbstractAction.php b/CommonLogic/AbstractAction.php index 2c012213d..58b8406be 100644 --- a/CommonLogic/AbstractAction.php +++ b/CommonLogic/AbstractAction.php @@ -4,6 +4,7 @@ use exface\Core\CommonLogic\Actions\ActionConfirmationList; use exface\Core\CommonLogic\Traits\ICanBeConvertedToUxonTrait; use exface\Core\Exceptions\Actions\ActionConfigurationError; +use exface\Core\Exceptions\Actions\ActionTaskInvalidException; use exface\Core\Interfaces\Actions\ActionConfirmationListInterface; use exface\Core\Interfaces\DataSheets\DataSheetInterface; use exface\Core\Interfaces\Log\LoggerInterface; @@ -347,6 +348,9 @@ public final function handle(TaskInterface $task, DataTransactionInterface $tran $transaction = $this->getWorkbench()->data()->startTransaction(); } + // TODO What's the correct response here? Throw, silent, message or something else. + $this->validateApplicability(new ActionInputValidator($this, $task)); + $this->getWorkbench()->eventManager()->dispatch(new OnBeforeActionPerformedEvent($this, $task, $transaction, function() use ($task) { return $this->getInputDataSheet($task); })); @@ -387,6 +391,20 @@ public final function handle(TaskInterface $task, DataTransactionInterface $tran return $result; } + /** + * Validates whether this action can be applied to a given task. Throws an error, when encountering issues + * and returns `void` if the task is valid for this action. + * + * Base validation ensures that the task object and action object match, provided both are defined. + * + * @throws ActionTaskInvalidException + * Throws an exception if validation FAILS, containing a description of the violation. + */ + protected function validateApplicability(ActionInputValidator $validator) : void + { + $validator->validateTaskObject(); + } + /** * * {@inheritdoc} diff --git a/CommonLogic/ActionInputValidator.php b/CommonLogic/ActionInputValidator.php new file mode 100644 index 000000000..880900ecb --- /dev/null +++ b/CommonLogic/ActionInputValidator.php @@ -0,0 +1,249 @@ +action = $action; + $this->task = $task; + } + + /** + * Validates the task object and throws an error, should validation fail. + * + * NOTE: If either the task or the action do not have an object, validation succeeds. + * + * @return void + * @throws ActionTaskInvalidException + */ + public function validateTaskObject() : void + { + $action = $this->getAction(); + $task = $this->getTask(); + + $taskObject = $task->hasMetaObject() ? $task->getMetaObject() : null; + $actionObject = $action->hasMetaObject() ? $action->getMetaObject() : null; + + if($taskObject === null || $actionObject === null) { + return; + } + + // Ensure metaobjects match. + if(!$taskObject->isExactly($actionObject)) { + + // See if any input mapper has a matching from object. + // If we can't match with an input mapper either, the task is invalid for this action. + if(!$action->getInputMapper($taskObject) !== null) { + $taskAlias = $taskObject->getAliasWithNamespace(); + + $error = new ActionTaskInvalidException( + $action, + $task, + 'Action "' . $action->getAliasWithNamespace() . '" is defined for "' . + $actionObject->getAliasWithNamespace() . '", but received a task with object "' . + $taskAlias . '"!' + ); + + $error->setUseExceptionMessageAsTitle(true); + $error->addIssue(ActionTaskInvalidException::ISSUE_INVALID_OBJECT, $taskAlias); + + throw $error; + } + } + } + + /** + * Returns an array containing the names of columns that the action expects in its input data. + * To explicitly allow certain columns, simply add them to the array. + * + * @param string $widgetPrepareDataSheetFunction + * You can specify what getter you wish to use, to retrieve a datasheet from the input widget. Make sure the + * function is actually supported by the input widget and returns an instance of `DataSheetInterface`. + * @param WidgetInterface|null $inputWidget + * Specify an input widget. If ´null´, the input widget will be determined automatically. + * @return array + */ + public function getExpectedColumns( + string $widgetPrepareDataSheetFunction = 'prepareDataSheetToRead', + WidgetInterface $inputWidget = null + ) : array + { + $expectedColumns = []; + + $inputWidget = $inputWidget ?? $this->getInputWidget(); + if($inputWidget === null) { + return $expectedColumns; + } + + $this->addColumnsFromWidget( + $expectedColumns, + $inputWidget, + $widgetPrepareDataSheetFunction + ); + + if($inputWidget instanceof iHaveConfigurator) { + $this->addColumnsFromWidget( + $expectedColumns, + $inputWidget->getConfiguratorWidget(), + 'prepareDataSheetToRead' + ); + } + + if($inputWidget instanceof iHaveColumns) { + foreach ($inputWidget->getColumns() as $column) { + $name = $column->getDataColumnName(); + $expectedColumns[$name] = $name; + } + } + + return $expectedColumns; + } + + /** + * Deduces the input widget for the action. + * + * @return WidgetInterface|null + */ + protected function getInputWidget() : WidgetInterface|null + { + $task = $this->getTask(); + $action = $this->getAction(); + + if($task->isTriggeredByWidget()) { + $widget = $task->getWidgetTriggeredBy(); + } elseif ($action->isDefinedInWidget()) { + $widget = $action->getWidgetDefinedIn(); + } else { + return null; + } + + if($widget instanceof iUseInputWidget) { + $widget = $widget->getInputWidget(); + } + + return $widget; + } + + /** + * Adds all columns from a given widget to + * + * @param array $target + * @param WidgetInterface $widget + * @param string $widgetPrepareDataSheetFunction + * @return void + */ + protected function addColumnsFromWidget( + array &$target, + WidgetInterface $widget, + string $widgetPrepareDataSheetFunction + ) : void + { + try { + foreach ($widget->$widgetPrepareDataSheetFunction()->getColumns() as $column) { + $name = $column->getName(); + $target[$name] = $name; + } + } catch (\Throwable $exception) { + + } + } + + /** + * Validates the task against a given list of columns. If any column in the task's input data is NOT among the + * `$expectedColumns` an error will be thrown. + * + * Validation succeeds if all input columns are expected or if the task doesn't have input data. + * + * @param array $expectedColumns + * @return void + */ + public function validateTaskColumns( + array $expectedColumns + ) : void + { + if(empty($expectedColumns)) { + return; + } + + $action = $this->getAction(); + $task = $this->getTask(); + + // See if the task has input data. + if(!$task->hasInputData()) { + return; + } + + $taskInput = $task->getInputData(); + $unexpectedColumns = []; + + // Now check if there are input columns that are not present in our expected structure, which would imply + // a mistake or unauthorized attempts at accessing or manipulating data. Note that missing input columns + // are of no concern here, since they might be handled later by mappers or prototype specific logic. + foreach ($taskInput->getColumns() as $inputColumn) { + $inputColumnName = $inputColumn->getName(); + if(!key_exists($inputColumnName, $expectedColumns)) { + $unexpectedColumns[$inputColumnName] = '"' . $inputColumnName . '"'; + } + } + + if(!empty($unexpectedColumns)) { + $error = new ActionTaskInvalidException( + $action, + $task, + 'Unexpected task input columns detected for action "' . $action->getAliasWithNamespace() . + '": ' . implode(', ', $unexpectedColumns) . '!' + ); + + $error->setUseExceptionMessageAsTitle(true); + foreach (array_keys($unexpectedColumns) as $unexpectedColumn) { + $error->addIssue(ActionTaskInvalidException::ISSUE_UNEXPECTED_COLUMN, $unexpectedColumn); + } + + throw $error; + } + } + + /** + * @return ActionInterface + */ + public function getAction() : ActionInterface + { + return $this->action; + } + + /** + * @return TaskInterface + */ + public function getTask() : TaskInterface + { + return $this->task; + } +} \ No newline at end of file diff --git a/Exceptions/Actions/ActionTaskInvalidException.php b/Exceptions/Actions/ActionTaskInvalidException.php new file mode 100644 index 000000000..6d41e07e8 --- /dev/null +++ b/Exceptions/Actions/ActionTaskInvalidException.php @@ -0,0 +1,41 @@ +task = $task; + parent::__construct($action, $message, $alias, $previous); + } + + public function getTask() : TaskInterface + { + return $this->task; + } + + public function addIssue(string $issue, string $alias) : void + { + $this->issues[$issue][$alias] = $alias; + } + + public function getIssuesAll() : array + { + return $this->issues; + } + + public function getIssue(string $issue) : array + { + return $this->issues[$issue] ?? []; + } +} \ No newline at end of file