From: Coleman Watts Date: Tue, 12 Oct 2021 02:32:28 +0000 (-0400) Subject: APIv4 - Dispatch event during Entity.get X-Git-Url: https://vcs.fsf.org/?a=commitdiff_plain;h=0b2471a8fc2c4e4f210e0d21e6fa2b51e4351504;p=civicrm-core.git APIv4 - Dispatch event during Entity.get This allows extensions to modify the list of entities, enabling "virtual" entities not based on php files. --- diff --git a/Civi/Api4/Action/Entity/Get.php b/Civi/Api4/Action/Entity/Get.php index 024ffadba2..6f939e84d4 100644 --- a/Civi/Api4/Action/Entity/Get.php +++ b/Civi/Api4/Action/Entity/Get.php @@ -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. @@ -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; } @@ -81,24 +73,20 @@ class Get extends \Civi\Api4\Generic\BasicGetAction { * @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; } @@ -109,39 +97,35 @@ class Get extends \Civi\Api4\Generic\BasicGetAction { * @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; } diff --git a/Civi/Api4/Generic/AbstractEntity.php b/Civi/Api4/Generic/AbstractEntity.php index 266c6307a4..735327f88f 100644 --- a/Civi/Api4/Generic/AbstractEntity.php +++ b/Civi/Api4/Generic/AbstractEntity.php @@ -129,45 +129,40 @@ abstract class AbstractEntity { * 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; } diff --git a/Civi/Api4/Utils/CoreUtil.php b/Civi/Api4/Utils/CoreUtil.php index 1dbf631603..62ee8124d9 100644 --- a/Civi/Api4/Utils/CoreUtil.php +++ b/Civi/Api4/Utils/CoreUtil.php @@ -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; @@ -29,14 +30,8 @@ class CoreUtil { 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; } /** @@ -57,8 +52,11 @@ class CoreUtil { * @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; } /** diff --git a/tests/phpunit/api/v3/CampaignTest.php b/tests/phpunit/api/v3/CampaignTest.php index 7f32427506..86859bbc3e 100644 --- a/tests/phpunit/api/v3/CampaignTest.php +++ b/tests/phpunit/api/v3/CampaignTest.php @@ -26,6 +26,7 @@ class api_v3_CampaignTest extends CiviUnitTestCase { 'created_date' => 'first sat of July 2008', ]; parent::setUp(); + \CRM_Core_BAO_ConfigSetting::enableComponent('CiviCampaign'); $this->useTransaction(TRUE); } diff --git a/tests/phpunit/api/v4/Action/BasicActionsTest.php b/tests/phpunit/api/v4/Action/BasicActionsTest.php index d8d5e9d1c1..53edd99f55 100644 --- a/tests/phpunit/api/v4/Action/BasicActionsTest.php +++ b/tests/phpunit/api/v4/Action/BasicActionsTest.php @@ -21,11 +21,29 @@ namespace api\v4\Action; 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(); @@ -38,6 +56,7 @@ class BasicActionsTest extends UnitTestCase { $info = MockBasicEntity::getInfo(); $this->assertEquals('MockBasicEntity', $info['name']); $this->assertEquals(['identifier'], $info['primary_key']); + $this->assertEquals('identifier', CoreUtil::getIdFieldName('MockBasicEntity')); } public function testCrud() {