Skip to content

Commit

Permalink
APIv4 - Dispatch event during Entity.get
Browse files Browse the repository at this point in the history
This allows extensions to modify the list of entities,
enabling "virtual" entities not based on php files.
  • Loading branch information
colemanw committed Oct 12, 2021
1 parent 6478fb5 commit 0b2471a
Show file tree
Hide file tree
Showing 5 changed files with 110 additions and 113 deletions.
118 changes: 51 additions & 67 deletions Civi/Api4/Action/Entity/Get.php
Original file line number Diff line number Diff line change
Expand Up @@ -12,10 +12,10 @@

namespace Civi\Api4\Action\Entity;

use Civi\Api4\CustomGroup;
use Civi\Api4\CustomValue;
use Civi\Api4\Service\Schema\Joinable\CustomGroupJoinable;
use Civi\Api4\Utils\CoreUtil;
use Civi\Core\Event\GenericHookEvent;

/**
* Get the names & docblocks of all APIv4 entities.
Expand All @@ -36,31 +36,23 @@ class Get extends \Civi\Api4\Generic\BasicGetAction {
* Scan all api directories to discover entities
*/
protected function getRecords() {
$entities = [];
$namesRequested = $this->_itemsToGet('name');
$cache = \Civi::cache('metadata');
$entities = $cache->get('api4.entities.info', []);

if ($namesRequested) {
foreach ($namesRequested as $entityName) {
if (strpos($entityName, 'Custom_') !== 0) {
$className = CoreUtil::getApiClass($entityName);
if ($className) {
$this->loadEntity($className, $entities);
}
}
}
}
else {
if (!$entities) {
foreach ($this->getAllApiClasses() as $className) {
// Load entities declared in API files
$this->loadEntity($className, $entities);
// Load entities based on custom data
$entities = array_merge($entities, $this->getCustomEntities());
// Allow extensions to modify the list of entities
$event = GenericHookEvent::create(['entities' => &$entities]);
\Civi::dispatcher()->dispatch('civi.api4.entityTypes', $event);
}
ksort($entities);
$cache->set('api4.entities.info', $entities);
}

// Fetch custom entities unless we've already fetched everything requested
if (!$namesRequested || array_diff($namesRequested, array_keys($entities))) {
$entities = array_merge($entities, $this->getCustomEntities());
}

ksort($entities);
return $entities;
}

Expand All @@ -81,24 +73,20 @@ private function loadEntity($className, array &$entities) {
* @return \Civi\Api4\Generic\AbstractEntity[]
*/
private function getAllApiClasses() {
$cache = \Civi::cache('metadata');
$classNames = $cache->get('api4.entities.classNames', []);
if (!$classNames) {
$locations = array_merge([\Civi::paths()->getPath('[civicrm.root]/Civi.php')],
array_column(\CRM_Extension_System::singleton()->getMapper()->getActiveModuleFiles(), 'filePath')
);
foreach ($locations as $location) {
$dir = \CRM_Utils_File::addTrailingSlash(dirname($location)) . 'Civi/Api4';
if (is_dir($dir)) {
foreach (glob("$dir/*.php") as $file) {
$className = 'Civi\Api4\\' . basename($file, '.php');
if (is_a($className, 'Civi\Api4\Generic\AbstractEntity', TRUE)) {
$classNames[] = $className;
}
$classNames = [];
$locations = array_merge([\Civi::paths()->getPath('[civicrm.root]/Civi.php')],
array_column(\CRM_Extension_System::singleton()->getMapper()->getActiveModuleFiles(), 'filePath')
);
foreach ($locations as $location) {
$dir = \CRM_Utils_File::addTrailingSlash(dirname($location)) . 'Civi/Api4';
if (is_dir($dir)) {
foreach (glob("$dir/*.php") as $file) {
$className = 'Civi\Api4\\' . basename($file, '.php');
if (is_a($className, 'Civi\Api4\Generic\AbstractEntity', TRUE)) {
$classNames[] = $className;
}
}
}
$cache->set('api4.entities.classNames', $classNames);
}
return $classNames;
}
Expand All @@ -109,39 +97,35 @@ private function getAllApiClasses() {
* @return array[]
*/
private function getCustomEntities() {
$cache = \Civi::cache('metadata');
$entities = $cache->get('api4.entities.custom');
if (!isset($entities)) {
$entities = [];
$customEntities = CustomGroup::get()
->addWhere('is_multiple', '=', 1)
->addWhere('is_active', '=', 1)
->setSelect(['name', 'title', 'help_pre', 'help_post', 'extends', 'icon'])
->setCheckPermissions(FALSE)
->execute();
$baseInfo = CustomValue::getInfo();
foreach ($customEntities as $customEntity) {
$fieldName = 'Custom_' . $customEntity['name'];
$baseEntity = CoreUtil::getApiClass(CustomGroupJoinable::getEntityFromExtends($customEntity['extends']));
$entities[$fieldName] = [
'name' => $fieldName,
'title' => $customEntity['title'],
'title_plural' => $customEntity['title'],
'description' => ts('Custom group for %1', [1 => $baseEntity::getInfo()['title_plural']]),
'paths' => [
'view' => "civicrm/contact/view/cd?reset=1&gid={$customEntity['id']}&recId=[id]&multiRecordDisplay=single",
],
'icon' => $customEntity['icon'] ?: NULL,
] + $baseInfo;
if (!empty($customEntity['help_pre'])) {
$entities[$fieldName]['comment'] = $this->plainTextify($customEntity['help_pre']);
}
if (!empty($customEntity['help_post'])) {
$pre = empty($entities[$fieldName]['comment']) ? '' : $entities[$fieldName]['comment'] . "\n\n";
$entities[$fieldName]['comment'] = $pre . $this->plainTextify($customEntity['help_post']);
}
$entities = [];
$baseInfo = CustomValue::getInfo();
$select = \CRM_Utils_SQL_Select::from('civicrm_custom_group')
->where('is_multiple = 1')
->where('is_active = 1')
->toSQL();
$group = \CRM_Core_DAO::executeQuery($select);
while ($group->fetch()) {
$fieldName = 'Custom_' . $group->name;
$baseEntity = CoreUtil::getApiClass(CustomGroupJoinable::getEntityFromExtends($group->extends));
$entities[$fieldName] = [
'name' => $fieldName,
'title' => $group->title,
'title_plural' => $group->title,
'description' => ts('Custom group for %1', [1 => $baseEntity::getInfo()['title_plural']]),
'paths' => [
'view' => "civicrm/contact/view/cd?reset=1&gid={$group->id}&recId=[id]&multiRecordDisplay=single",
],
] + $baseInfo;
if (!empty($group->icon)) {
$entities[$fieldName]['icon'] = $group->icon;
}
if (!empty($group->help_pre)) {
$entities[$fieldName]['comment'] = $this->plainTextify($group->help_pre);
}
if (!empty($group->help_post)) {
$pre = empty($entities[$fieldName]['comment']) ? '' : $entities[$fieldName]['comment'] . "\n\n";
$entities[$fieldName]['comment'] = $pre . $this->plainTextify($group->help_post);
}
$cache->set('api4.entities.custom', $entities);
}
return $entities;
}
Expand Down
65 changes: 30 additions & 35 deletions Civi/Api4/Generic/AbstractEntity.php
Original file line number Diff line number Diff line change
Expand Up @@ -129,45 +129,40 @@ public static function __callStatic($action, $args) {
* Reflection function called by Entity::get()
*
* @see \Civi\Api4\Action\Entity\Get
* @return array
* @return array{name: string, title: string, description: string, title_plural: string, type: string, paths: array, class: string, primary_key: array, searchable: string, dao: string, label_field: string, icon: string}
*/
public static function getInfo() {
$cache = \Civi::cache('metadata');
$entityName = static::getEntityName();
$info = $cache->get("api4.$entityName.info");
if (!$info) {
$info = [
'name' => $entityName,
'title' => static::getEntityTitle(),
'title_plural' => static::getEntityTitle(TRUE),
'type' => [self::stripNamespace(get_parent_class(static::class))],
'paths' => static::getEntityPaths(),
'class' => static::class,
'primary_key' => ['id'],
// Entities without a @searchable annotation will default to secondary,
// which makes them visible in SearchKit but not at the top of the list.
'searchable' => 'secondary',
];
// Add info for entities with a corresponding DAO
$dao = \CRM_Core_DAO_AllCoreTables::getFullName($info['name']);
if ($dao) {
$info['paths'] = $dao::getEntityPaths();
$info['primary_key'] = $dao::$_primaryKey;
$info['icon'] = $dao::$_icon;
$info['label_field'] = $dao::$_labelField;
$info['dao'] = $dao;
}
foreach (ReflectionUtils::getTraits(static::class) as $trait) {
$info['type'][] = self::stripNamespace($trait);
}
$reflection = new \ReflectionClass(static::class);
$info = array_merge($info, ReflectionUtils::getCodeDocs($reflection, NULL, ['entity' => $info['name']]));
if ($dao) {
$info['description'] = $dao::getEntityDescription() ?? $info['description'] ?? NULL;
}
unset($info['package'], $info['method']);
$cache->set("api4.$entityName.info", $info);
$info = [
'name' => $entityName,
'title' => static::getEntityTitle(),
'title_plural' => static::getEntityTitle(TRUE),
'type' => [self::stripNamespace(get_parent_class(static::class))],
'paths' => static::getEntityPaths(),
'class' => static::class,
'primary_key' => ['id'],
// Entities without a @searchable annotation will default to secondary,
// which makes them visible in SearchKit but not at the top of the list.
'searchable' => 'secondary',
];
// Add info for entities with a corresponding DAO
$dao = \CRM_Core_DAO_AllCoreTables::getFullName($info['name']);
if ($dao) {
$info['paths'] = $dao::getEntityPaths();
$info['primary_key'] = $dao::$_primaryKey;
$info['icon'] = $dao::$_icon;
$info['label_field'] = $dao::$_labelField;
$info['dao'] = $dao;
}
foreach (ReflectionUtils::getTraits(static::class) as $trait) {
$info['type'][] = self::stripNamespace($trait);
}
$reflection = new \ReflectionClass(static::class);
$info = array_merge($info, ReflectionUtils::getCodeDocs($reflection, NULL, ['entity' => $info['name']]));
if ($dao) {
$info['description'] = $dao::getEntityDescription() ?? $info['description'] ?? NULL;
}
unset($info['package'], $info['method']);
return $info;
}

Expand Down
18 changes: 8 additions & 10 deletions Civi/Api4/Utils/CoreUtil.php
Original file line number Diff line number Diff line change
Expand Up @@ -13,6 +13,7 @@
namespace Civi\Api4\Utils;

use Civi\API\Request;
use Civi\Api4\Entity;
use Civi\Api4\Event\CreateApi4RequestEvent;
use CRM_Core_DAO_AllCoreTables as AllCoreTables;

Expand All @@ -29,14 +30,8 @@ public static function getBAOFromApiName($entityName) {
if ($entityName === 'CustomValue' || strpos($entityName, 'Custom_') === 0) {
return 'CRM_Core_BAO_CustomValue';
}
$dao = self::getInfoItem($entityName, 'dao');
if (!$dao) {
return NULL;
}
$bao = str_replace("DAO", "BAO", $dao);
// Check if this entity actually has a BAO. Fall back on the DAO if not.
$file = strtr($bao, '_', '/') . '.php';
return stream_resolve_include_path($file) ? $bao : $dao;
$dao = AllCoreTables::getFullName($entityName);
return $dao ? AllCoreTables::getBAOClassName($dao) : NULL;
}

/**
Expand All @@ -57,8 +52,11 @@ public static function getApiClass($entityName) {
* @return mixed
*/
public static function getInfoItem(string $entityName, string $keyToReturn) {
$className = self::getApiClass($entityName);
return $className ? $className::getInfo()[$keyToReturn] ?? NULL : NULL;
$info = Entity::get(FALSE)
->addWhere('name', '=', $entityName)
->addSelect($keyToReturn)
->execute()->first();
return $info ? $info[$keyToReturn] ?? NULL : NULL;
}

/**
Expand Down
1 change: 1 addition & 0 deletions tests/phpunit/api/v3/CampaignTest.php
Original file line number Diff line number Diff line change
Expand Up @@ -26,6 +26,7 @@ public function setUp(): void {
'created_date' => 'first sat of July 2008',
];
parent::setUp();
\CRM_Core_BAO_ConfigSetting::enableComponent('CiviCampaign');
$this->useTransaction(TRUE);
}

Expand Down
21 changes: 20 additions & 1 deletion tests/phpunit/api/v4/Action/BasicActionsTest.php
Original file line number Diff line number Diff line change
Expand Up @@ -21,11 +21,29 @@

use api\v4\UnitTestCase;
use Civi\Api4\MockBasicEntity;
use Civi\Api4\Utils\CoreUtil;
use Civi\Core\Event\GenericHookEvent;
use Civi\Test\HookInterface;

/**
* @group headless
*/
class BasicActionsTest extends UnitTestCase {
class BasicActionsTest extends UnitTestCase implements HookInterface {

/**
* Listens for civi.api4.entityTypes event to manually add this nonstandard entity
*
* @param \Civi\Core\Event\GenericHookEvent $e
*/
public function on_civi_api4_entityTypes(GenericHookEvent $e): void {
$e->entities['MockBasicEntity'] = MockBasicEntity::getInfo();
}

public function setUpHeadless() {
// Ensure MockBasicEntity gets added via above listener
\Civi::cache('metadata')->clear();
return parent::setUpHeadless();
}

private function replaceRecords(&$records) {
MockBasicEntity::delete()->addWhere('identifier', '>', 0)->execute();
Expand All @@ -38,6 +56,7 @@ public function testGetInfo() {
$info = MockBasicEntity::getInfo();
$this->assertEquals('MockBasicEntity', $info['name']);
$this->assertEquals(['identifier'], $info['primary_key']);
$this->assertEquals('identifier', CoreUtil::getIdFieldName('MockBasicEntity'));
}

public function testCrud() {
Expand Down

0 comments on commit 0b2471a

Please sign in to comment.