diff --git a/Civi/Api4/Action/CustomGroup/Get.php b/Civi/Api4/Action/CustomGroup/Get.php new file mode 100644 index 000000000000..5ad9da1f1193 --- /dev/null +++ b/Civi/Api4/Action/CustomGroup/Get.php @@ -0,0 +1,107 @@ +useCache)) { + $this->useCache = !$this->needDatabase(); + } + if ($this->useCache) { + $this->getFromCache($result); + return; + } + parent::getObjects($result); + } + + /** + * Determine whether this query needs to use the + * database (or can be answered using the cache) + * + * @return bool + */ + protected function needDatabase(): bool { + if ($this->groupBy || $this->having || $this->join) { + return TRUE; + } + + $standardFields = \Civi::entity($this->getEntityName())->getFields(); + foreach ($this->select as $field) { + [$field] = explode(':', $field); + if (!isset($standardFields[$field])) { + return TRUE; + } + } + foreach ($this->where as $clause) { + [$field] = explode(':', $clause[0] ?? ''); + if (!$field || !isset($standardFields[$field])) { + return TRUE; + } + // ArrayQueryTrait doesn't yet support field-to-field comparisons + if (!empty($clause[3])) { + return TRUE; + } + } + foreach ($this->orderBy as $field => $dir) { + [$field] = explode(':', $field); + if (!isset($standardFields[$field])) { + return TRUE; + } + } + return FALSE; + } + + /** + * This works like BasicGetAction: + * - provide all the records upfront from the cache + * - format suffixes using PseudoconstantOutputTrait + * - filter using ArrayQueryActionTrait + */ + protected function getFromCache($result): void { + $values = $this->getCachedRecords(); + $this->formatRawValues($values); + $this->queryArray($values, $result); + } + + protected function getCachedRecords() { + return \CRM_Core_BAO_CustomGroup::getAll(); + } + +} diff --git a/Civi/Api4/CustomGroup.php b/Civi/Api4/CustomGroup.php index 1aed113a4860..4b13fda73893 100644 --- a/Civi/Api4/CustomGroup.php +++ b/Civi/Api4/CustomGroup.php @@ -23,4 +23,13 @@ class CustomGroup extends Generic\DAOEntity { use Generic\Traits\ManagedEntity; use Generic\Traits\SortableEntity; + /** + * @param bool $checkPermissions + * @return Action\CustomGroup\Get + */ + public static function get($checkPermissions = TRUE) { + return (new Action\CustomGroup\Get(__CLASS__, __FUNCTION__)) + ->setCheckPermissions($checkPermissions); + } + } diff --git a/Civi/Api4/Generic/BasicGetAction.php b/Civi/Api4/Generic/BasicGetAction.php index d8273e0dad56..24b261896d57 100644 --- a/Civi/Api4/Generic/BasicGetAction.php +++ b/Civi/Api4/Generic/BasicGetAction.php @@ -13,7 +13,6 @@ namespace Civi\Api4\Generic; use Civi\API\Exception\NotImplementedException; -use Civi\Api4\Utils\FormattingUtil; /** * Retrieve $ENTITIES based on criteria specified in the `where` parameter. @@ -22,6 +21,7 @@ */ class BasicGetAction extends AbstractGetAction { use Traits\ArrayQueryActionTrait; + use Traits\PseudoconstantOutputTrait; /** * @var callable @@ -94,30 +94,4 @@ protected function getRecords() { throw new NotImplementedException('Getter function not found for api4 ' . $this->getEntityName() . '::' . $this->getActionName()); } - /** - * Evaluate :pseudoconstant suffix expressions and replace raw values with option values - * - * @param $records - * @throws \CRM_Core_Exception - */ - protected function formatRawValues(&$records) { - // Pad $records and $fields with pseudofields - $fields = $this->entityFields(); - foreach ($records as &$values) { - foreach ($this->entityFields() as $field) { - $values += [$field['name'] => $field['default_value'] ?? NULL]; - if (!empty($field['options'])) { - foreach ($field['suffixes'] ?? array_keys(\CRM_Core_SelectValues::optionAttributes()) as $suffix) { - $pseudofield = $field['name'] . ':' . $suffix; - if (!isset($values[$pseudofield]) && isset($values[$field['name']]) && $this->_isFieldSelected($pseudofield)) { - $values[$pseudofield] = $values[$field['name']]; - } - } - } - } - // Swap raw values with pseudoconstants - FormattingUtil::formatOutputValues($values, $fields, $this->getActionName()); - } - } - } diff --git a/Civi/Api4/Generic/Traits/PseudoconstantOutputTrait.php b/Civi/Api4/Generic/Traits/PseudoconstantOutputTrait.php new file mode 100644 index 000000000000..eb721ed19556 --- /dev/null +++ b/Civi/Api4/Generic/Traits/PseudoconstantOutputTrait.php @@ -0,0 +1,50 @@ +entityFields(); + foreach ($records as &$values) { + foreach ($this->entityFields() as $field) { + $values += [$field['name'] => $field['default_value'] ?? NULL]; + if (!empty($field['options'])) { + foreach ($field['suffixes'] ?? array_keys(\CRM_Core_SelectValues::optionAttributes()) as $suffix) { + $pseudofield = $field['name'] . ':' . $suffix; + if (!isset($values[$pseudofield]) && isset($values[$field['name']]) && $this->_isFieldSelected($pseudofield)) { + $values[$pseudofield] = $values[$field['name']]; + } + } + } + } + // Swap raw values with pseudoconstants + FormattingUtil::formatOutputValues($values, $fields, $this->getActionName()); + } + } + +} diff --git a/tests/phpunit/api/v4/Action/CachedGetTest.php b/tests/phpunit/api/v4/Action/CachedGetTest.php new file mode 100644 index 000000000000..3982651fc80e --- /dev/null +++ b/tests/phpunit/api/v4/Action/CachedGetTest.php @@ -0,0 +1,129 @@ +addValue('name', 'LemonPreferences') + ->addValue('title', 'Lemon') + ->addValue('extends', 'Contact') + ->addValue('pre_help', 'Some people think lemons are all the same, but some have preferences.') + ->execute(); + } + + /** + * @inheritDoc + */ + public function tearDown(): void { + CustomGroup::delete(FALSE) + ->addWhere('name', '=', 'LemonPreferences') + ->execute(); + parent::tearDown(); + } + + /** + * @return \Civi\Api4\Action\AbstractAction[] + */ + protected function cacheableCalls(): array { + $calls = []; + + $calls[] = CustomGroup::get(FALSE) + ->addWhere('name', 'CONTAINS', 'lemon'); + + // note: comparison should be case-insensitive + $calls[] = CustomGroup::get(FALSE) + ->addWhere('extends', '=', 'contact'); + + return $calls; + } + + /** + * @return \Civi\Api4\Action\AbstractAction[] + */ + protected function uncacheableCalls(): array { + $calls = []; + + // cache cant GROUP BY + $calls[] = CustomGroup::get(FALSE) + ->addSelect('extends', 'COUNT(id) AS count') + ->addGroupBy('extends'); + + // cache cant do SQL functions + $calls[] = CustomGroup::get(FALSE) + ->addSelect('UPPER(name)'); + + // TODO: cache cant do implicit joins (but none available on CustomGroup) + + return $calls; + } + + /** + * For easy calls the cached result should + * match the database result + */ + public function testCachedGetMatchesDatabase(): void { + foreach ($this->cacheableCalls() as $call) { + // we need two copies of the API action object + $dbCall = clone $call; + + $cacheResult = (array) $call->setUseCache(TRUE)->execute(); + + $dbResult = (array) $dbCall->setUseCache(FALSE)->execute(); + + $this->assertEquals($cacheResult, $dbResult); + } + } + + /** + * For hard calls the default result should + * match the database result + * (the API should determine it needs to escalate + * to a DB call if `useCache` is left unspecified) + */ + public function testHardQueryUsesDatabaseByDefault(): void { + foreach ($this->uncacheableCalls() as $call) { + // we need two copies of the API action object + $dbCall = clone $call; + + $defaultResult = (array) $call->execute(); + + $dbResult = (array) $dbCall->setUseCache(FALSE)->execute(); + + $this->assertEquals($defaultResult, $dbResult); + } + } + +}