APIv4 - Dispatch event during Entity.get
authorColeman Watts <coleman@civicrm.org>
Tue, 12 Oct 2021 02:32:28 +0000 (22:32 -0400)
committerColeman Watts <coleman@civicrm.org>
Tue, 12 Oct 2021 22:23:53 +0000 (18:23 -0400)
This allows extensions to modify the list of entities,
enabling "virtual" entities not based on php files.

Civi/Api4/Action/Entity/Get.php
Civi/Api4/Generic/AbstractEntity.php
Civi/Api4/Utils/CoreUtil.php
tests/phpunit/api/v3/CampaignTest.php
tests/phpunit/api/v4/Action/BasicActionsTest.php

index 024ffadba26351c6364288f06d635c3264b64bad..6f939e84d4218ee282c09f83bf2b7d4841328ec3 100644 (file)
 
 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;
   }
index 266c6307a491f968ca5e5e8a1a3ffe6ec568a71e..735327f88fb977de795f811f03020d8407ecdef0 100644 (file)
@@ -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;
   }
 
index 1dbf6316039a92022f9ea992f0f7fa69716f592a..62ee8124d9c6459f0445240b0a6144d9b5ae1387 100644 (file)
@@ -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;
   }
 
   /**
index 7f324275068f106ffdd8691e18060d01fe1c69c5..86859bbc3e7b018c5fc705ef4c2cac3d76fe11b4 100644 (file)
@@ -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);
   }
 
index d8d5e9d1c16fc72701200d537f4ae224bda07084..53edd99f5519327d870a8de84a234f47129a51fb 100644 (file)
@@ -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() {