Skip to content

Commit

Permalink
Merge pull request civicrm#31508 from ufundo/api4-custom-group-cache
Browse files Browse the repository at this point in the history
Api4 CustomGroup.get - use in-memory cache to answer simple calls
  • Loading branch information
colemanw authored Dec 4, 2024
2 parents 055fc5a + cffd008 commit d15c448
Show file tree
Hide file tree
Showing 5 changed files with 296 additions and 27 deletions.
107 changes: 107 additions & 0 deletions Civi/Api4/Action/CustomGroup/Get.php
Original file line number Diff line number Diff line change
@@ -0,0 +1,107 @@
<?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 |
+--------------------------------------------------------------------+
*/

namespace Civi\Api4\Action\CustomGroup;

use Civi\Api4\Generic\Result;
use Civi\Api4\Generic\Traits\ArrayQueryActionTrait;
use Civi\Api4\Generic\Traits\PseudoconstantOutputTrait;

/**
* @inheritDoc
*
*/
class Get extends \Civi\Api4\Generic\DAOGetAction {
use ArrayQueryActionTrait;
use PseudoconstantOutputTrait;

/**
* @var bool
*
* Should we use the in-memory cache to answer
* this request?
*
* If unset, will be determined automatically based
* on the complexity of the request
*/
protected ?bool $useCache = NULL;

/**
* @param \Civi\Api4\Generic\Result $result
*
* Use self::getFromCache or DAOGetAction::getObjects
*/
protected function getObjects(Result $result): void {
if (is_null($this->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();
}

}
9 changes: 9 additions & 0 deletions Civi/Api4/CustomGroup.php
Original file line number Diff line number Diff line change
Expand Up @@ -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);
}

}
28 changes: 1 addition & 27 deletions Civi/Api4/Generic/BasicGetAction.php
Original file line number Diff line number Diff line change
Expand Up @@ -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.
Expand All @@ -22,6 +21,7 @@
*/
class BasicGetAction extends AbstractGetAction {
use Traits\ArrayQueryActionTrait;
use Traits\PseudoconstantOutputTrait;

/**
* @var callable
Expand Down Expand Up @@ -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());
}
}

}
50 changes: 50 additions & 0 deletions Civi/Api4/Generic/Traits/PseudoconstantOutputTrait.php
Original file line number Diff line number Diff line change
@@ -0,0 +1,50 @@
<?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 |
+--------------------------------------------------------------------+
*/

namespace Civi\Api4\Generic\Traits;

use Civi\Api4\Utils\FormattingUtil;

/**
* Helper function for formatting optionvalue/pseudoconstant fields
*
* @package Civi\Api4\Generic
*/
trait PseudoconstantOutputTrait {

/**
* 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());
}
}

}
129 changes: 129 additions & 0 deletions tests/phpunit/api/v4/Action/CachedGetTest.php
Original file line number Diff line number Diff line change
@@ -0,0 +1,129 @@
<?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 api\v4\Action;

use api\v4\Api4TestBase;
use Civi\Api4\CustomGroup;

/**
* Test Api4-level caching (currently = CustomGroup)
*
*
* @group headless
*/
class CachedGetTest extends Api4TestBase {

/**
* @inheritdoc
*/
public function setUp(): void {
parent::setUp();

CustomGroup::create(FALSE)
->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);
}
}

}

0 comments on commit d15c448

Please sign in to comment.