Skip to content
New issue

Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.

By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.

Already on GitHub? Sign in to your account

APIv4 - When running validateValues(), fire an event #20184

Closed
wants to merge 7 commits into from
Closed
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
25 changes: 25 additions & 0 deletions CRM/Utils/Array.php
Original file line number Diff line number Diff line change
Expand Up @@ -17,6 +17,31 @@
*/
class CRM_Utils_Array {

/**
* Cast a value to an array.
*
* This is similar to PHP's `(array)`, but it also converts iterators.
*
* @param mixed $value
* @return array
*/
public static function cast($value) {
if (is_array($value)) {
return $value;
}
if ($value instanceof CRM_Utils_LazyArray || $value instanceof ArrayObject) {
// iterator_to_array() would work here, but getArrayCopy() doesn't require actual iterations.
return $value->getArrayCopy();
}
if (is_iterable($value)) {
return iterator_to_array($value);
}
if (is_scalar($value)) {
return [$value];
}
throw new \RuntimeException(sprintf("Cannot cast %s to array", gettype($value)));
}

/**
* Returns $list[$key] if such element exists, or a default value otherwise.
*
Expand Down
95 changes: 95 additions & 0 deletions CRM/Utils/LazyArray.php
Original file line number Diff line number Diff line change
@@ -0,0 +1,95 @@
<?php
/*
+--------------------------------------------------------------------+
| Copyright CiviCRM LLC. All rights reserved. |
| |
| This work is published under the GNU AGPLv3 license with some |
| permitted exceptions and without any warranty. For full license |
| and copyright information, see https://civicrm.org/licensing |
+--------------------------------------------------------------------+
*/

/**
* A lazy-array works much like a regular array or ArrayObject. However, it is
* initially empty - and it is only populated if used.
*/
class CRM_Utils_LazyArray implements ArrayAccess, IteratorAggregate, Countable {

/**
* A function which generates a list of values.
*
* @var callable
* function(): iterable
*/
private $func;

/**
* Cached values
*
* @var array|null
*/
private $cache;

/**
* CRM_Utils_LazyList constructor.
*
* @param callable $func
* Function which provides a list of values (array/iterator/generator).
*/
public function __construct($func) {
$this->func = $func;
}

/**
* Determine if the content has been fetched.
*
* @return bool
*/
public function isLoaded() {
return $this->cache !== NULL;
}

public function load($force = FALSE) {
if ($this->cache === NULL || $force) {
$this->cache = CRM_Utils_Array::cast(call_user_func($this->func));
}
return $this;
}

public function offsetExists($offset) {
return isset($this->load()->cache[$offset]);
}

public function &offsetGet($offset) {
return $this->load()->cache[$offset];
}

public function offsetSet($offset, $value) {
if ($offset === NULL) {
$this->load()->cache[] = $value;
}
else {
$this->load()->cache[$offset] = $value;
}
}

public function offsetUnset($offset) {
unset($this->load()->cache[$offset]);
}

public function getIterator() {
return new ArrayIterator($this->load()->cache);
}

/**
* @return array
*/
public function getArrayCopy() {
return $this->load()->cache;
}

public function count() {
return count($this->load()->cache);
}

}
176 changes: 176 additions & 0 deletions Civi/Api4/Event/ValidateValuesEvent.php
Original file line number Diff line number Diff line change
@@ -0,0 +1,176 @@
<?php

/*
+--------------------------------------------------------------------+
| Copyright CiviCRM LLC. All rights reserved. |
| |
| This work is published under the GNU AGPLv3 license with some |
| permitted exceptions and without any warranty. For full license |
| and copyright information, see https://civicrm.org/licensing |
+--------------------------------------------------------------------+
*/

/**
*
* @package CRM
* @copyright CiviCRM LLC https://civicrm.org/licensing
*/

namespace Civi\Api4\Event;

use Civi\Core\Event\GenericHookEvent;

/**
* The ValidateValuesEvent ('civi.api4.validate') is emitted when creating or saving an entire record via APIv4.
* It is emitted once for every record is updated.
*
* Example #1: Walk each record and validate some fields
*
* function(ValidateValuesEvent $e) {
* if ($e->entity !== 'Foozball') return;
* foreach ($e->records as $r => $record) {
* if (strtotime($record['start_time']) < CRM_Utils_Time::time()) {
* $e->addError($r, 'start_time', 'past', ts('Start time has already passed.'));
* }
* if ($record['length'] * $record['width'] * $record['height'] > VOLUME_LIMIT) {
* $e->addError($r, ['length', 'width', 'height'], 'excessive_volume', ts('The record is too big.'));
* }
* }
* }
*
* Example #2: Prohibit recording `Contribution` records on `Student` contacts.
*
* function(ValidateValuesEvent $e) {
* if ($e->entity !== 'Contribution') return;
* $contactSubTypes = CRM_Utils_SQL_Select::from('civicrm_contact')
* ->where('id IN (#ids)', ['ids' => array_column($e->records, 'contact_id')])
* ->select('id, contact_sub_type')
* ->execute()->fetchMap('id', 'contact_sub_type');
* foreach ($e->records as $r => $record) {
* if ($contactSubTypes[$record['contact_id']] === 'Student') {
* $e->addError($r, 'contact_id', 'student_prohibited', ts('Donations from student records are strictly prohibited.'));
* }
* }
* }
*/
class ValidateValuesEvent extends GenericHookEvent {

/**
* Type of entity being validated. (APIv4 name).
*
* @var string
* Ex: 'Membership'
*/
public $entity;

/**
* Action being executed (APIv4 name).
*
* @var string
* Ex: 'create', 'save', 'update'.
*/
public $action;

/**
* List of updated records.
*
* The list of `$records` reflects only the list of new values assigned
* by this action. It may or may not correspond to an existing row in the database.
* It is similar to the `$records` list used by `save()`.
*
* @var array|\CRM_Utils_LazyArray
* @see \Civi\Api4\Generic\AbstractSaveAction::$records
*/
public $records;

/**
* Detailed, side-by-side comparison of old and new values.
*
* This requires loading the list of old values from the database. Consequently,
* reading `$diffs` is more expensive than reading `$records`, so you should only use it if
* really necessary.
*
* The list of $diffs may be important if you are enforcing a rule that involves
* multiple fields. (Ex: "Validate that the state_id and country_id match.")
*
* When possible, $records and $diffs will have the same number of items (with corresponding
* keys). However, in the case of a batch `update()`, the list of diffs will be longer.
*
* @var array|\CRM_Utils_LazyArray
* Each item is a record of the form ['old' => $fieldValues, 'new' => $fieldValues]
*/
public $diffs;

/**
* List of error messages.
*
* @var array
* Array(string $errorName => string $errorMessage)
* Note:
*/
public $errors = [];

/**
* ValidateValuesEvent constructor.
*
* @param \Civi\Api4\Generic\AbstractAction $apiCall
* @param array|\CRM_Utils_LazyArray $records
* List of updates (akin to SaveAction::$records).
* @param array|\CRM_Utils_LazyArray $diffs
* List of differences (comparing old values and new values).
*/
public function __construct($apiCall, $records, $diffs) {
$this->entity = $apiCall->getEntityName();
$this->action = $apiCall->getActionName();
$this->records = $records;
$this->diffs = $diffs;
$this->errors = [];
}

/**
* @inheritDoc
*/
public function getHookValues() {
return [$this->entity, $this->action, $this->records, &$this->errors];
}

/**
* Add an error.
*
* @param string|int $recordKey
* The validator may work with multiple records. This should identify the specific record.
* Each record is identified by its offset (`$records[$recordKey] === [...the record...]`).
* @param string|array $field
* The name of the field which has an error.
* If the error is multi-field (e.g. mismatched password-confirmation), then use an array.
* If the error is independent of any field, then use [].
* @param string $name
* @param string|NULL $message
* @return $this
*/
public function addError($recordKey, $field, string $name, string $message = NULL): self {
$this->errors[] = [
'record' => $recordKey,
'fields' => (array) $field,
'name' => $name,
'message' => $message ?: ts('Error code (%1)', [1 => $name]),
];
return $this;
}

/**
* Convert the list of errors an exception.
*
* @return \API_Exception
*/
public function toException() {
// We should probably have a better way to report the errors in a structured/list format.
return new \API_Exception(ts('Found %1 error(s) in submitted %2 record(s) of type "%3": %4', [
1 => count($this->errors),
2 => count(array_unique(array_column($this->errors, 'record'))),
3 => $this->entity,
4 => implode(', ', array_column($this->errors, 'message')),
]));
}

}
17 changes: 15 additions & 2 deletions Civi/Api4/Generic/AbstractBatchAction.php
Original file line number Diff line number Diff line change
Expand Up @@ -54,9 +54,23 @@ public function __construct($entityName, $actionName, $select = 'id') {
}

/**
* Get a list of records for this batch.
*
* @return array
*/
protected function getBatchRecords() {
return (array) $this->getBatchAction()->execute();
}

/**
* Get a query which resolves the list of records for this batch.
*
* This is similar to `getBatchRecords()`, but you may further refine the
* API call (e.g. selecting different fields or data-pages) before executing.
*
* @return \Civi\Api4\Generic\AbstractGetAction
*/
protected function getBatchAction() {
$params = [
'checkPermissions' => $this->checkPermissions,
'where' => $this->where,
Expand All @@ -67,8 +81,7 @@ protected function getBatchRecords() {
if (empty($this->reload)) {
$params['select'] = $this->select;
}

return (array) civicrm_api4($this->getEntityName(), 'get', $params);
return \Civi\API\Request::create($this->getEntityName(), 'get', ['version' => 4] + $params);
}

/**
Expand Down
10 changes: 10 additions & 0 deletions Civi/Api4/Generic/AbstractCreateAction.php
Original file line number Diff line number Diff line change
Expand Up @@ -19,6 +19,8 @@

namespace Civi\Api4\Generic;

use Civi\Api4\Event\ValidateValuesEvent;

/**
* Base class for all `Create` api actions.
*
Expand Down Expand Up @@ -59,10 +61,18 @@ public function addValue(string $fieldName, $value) {
* @throws \API_Exception
*/
protected function validateValues() {
// FIXME: There should be a protocol to report a full list of errors... Perhaps a subclass of API_Exception?
$unmatched = $this->checkRequiredFields($this->getValues());
if ($unmatched) {
throw new \API_Exception("Mandatory values missing from Api4 {$this->getEntityName()}::{$this->getActionName()}: " . implode(", ", $unmatched), "mandatory_missing", ["fields" => $unmatched]);
}
$e = new ValidateValuesEvent($this, [$this->getValues()], new \CRM_Utils_LazyArray(function () {
return [['old' => NULL, 'new' => $this->getValues()]];
}));
\Civi::dispatcher()->dispatch('civi.api4.validate', $e);
if (!empty($e->errors)) {
throw $e->toException();
}
}

}
Loading