ClassScanner - Add utility for scanning classes based on PHP interfaces
authorTim Otten <totten@civicrm.org>
Tue, 21 Jun 2022 09:11:44 +0000 (02:11 -0700)
committerTim Otten <totten@civicrm.org>
Tue, 28 Jun 2022 00:18:21 +0000 (17:18 -0700)
CRM/Extension/Manager/Module.php
CRM/Utils/Hook.php
CRM/Utils/System.php
Civi/Core/ClassScanner.php [new file with mode: 0644]

index 4dc170aaf35539523a8c6375198e98888c6c1b5b..b8d385cd59a3e1c384d7147fa38c763268c72cae 100644 (file)
@@ -97,6 +97,15 @@ class CRM_Extension_Manager_Module extends CRM_Extension_Manager_Base {
     $this->callHook($info, 'enable');
   }
 
+  public function onPostReplace(CRM_Extension_Info $oldInfo, CRM_Extension_Info $newInfo) {
+    // Like everything, ClassScanner is probably affected by pre-existing/long-standing issue dev/core#3686.
+    // This may mitigate a couple edge-cases. But really #3686 needs a different+deeper fix.
+    \Civi\Core\ClassScanner::cache('structure')->flush();
+    \Civi\Core\ClassScanner::cache('index')->flush();
+
+    parent::onPostReplace($oldInfo, $newInfo);
+  }
+
   /**
    * @param CRM_Extension_Info $info
    */
index c44273c8422974d632b177ed31f5b0f7d8252d81..b3a20a312f90bb264818f128502db5f2df4924f6 100644 (file)
@@ -1676,6 +1676,22 @@ abstract class CRM_Utils_Hook {
     );
   }
 
+  /**
+   * (EXPERIMENTAL) Scan extensions for a list of auto-registered interfaces.
+   *
+   * This hook is currently experimental. It is a means to implementing `mixin/scan-classes@1`.
+   * If there are no major difficulties circa 5.55, then it can be marked stable.
+   *
+   * @param string[] $classes
+   *   List of classes which may be of interest to the class-scanner.
+   */
+  public static function scanClasses(array &$classes) {
+    self::singleton()->invoke(['classes'], $classes, self::$_nullObject,
+      self::$_nullObject, self::$_nullObject, self::$_nullObject, self::$_nullObject,
+      'civicrm_scanClasses'
+    );
+  }
+
   /**
    * This hook is called when we are determining the contactID for a specific
    * email address
index 757a1c4070bf024fedd7fa6f7fac20c366a9cfef..d92b359d7e031a44d7325c2175d2a69239cd7233 100644 (file)
@@ -1497,6 +1497,7 @@ class CRM_Utils_System {
       Civi::cache('customData')->flush();
       Civi::cache('contactTypes')->clear();
       Civi::cache('metadata')->clear();
+      \Civi\Core\ClassScanner::cache('index')->flush();
       CRM_Extension_System::singleton()->getCache()->flush();
       CRM_Cxn_CiviCxnHttp::singleton()->getCache()->flush();
     }
diff --git a/Civi/Core/ClassScanner.php b/Civi/Core/ClassScanner.php
new file mode 100644 (file)
index 0000000..d1b3523
--- /dev/null
@@ -0,0 +1,245 @@
+<?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\Core;
+
+/**
+ * The ClassScanner is a helper for finding/loading classes based on their tagged interfaces.
+ *
+ * The implementation of scanning+caching are generally built on these assumptions:
+ *
+ * - Scanning the filesystem can be expensive. One scan should serve many consumers.
+ * - Consumers want to know about specific interfaces (`get(['interface' => 'CRM_Foo_BarInterface'])`.
+ *
+ * We reconcile these goals by performing a single scan and then storing separate cache-items for each
+ * known interface (eg `$cache->get(md5('CRM_Foo_BarInterface'))`).
+ */
+class ClassScanner {
+
+  /**
+   * We cache information about classes that support each interface. Which interfaces should we track?
+   */
+  const CIVI_INTERFACE_REGEX = ';^(CRM_|Civi\\\);';
+
+  /**
+   * We load PHP files to find classes. Which files should we load?
+   */
+  const CIVI_CLASS_FILE_REGEX = '/^([A-Z][A-Za-z0-9]*)\.php$/';
+
+  const TTL = 3 * 24 * 60 * 60;
+
+  /**
+   * @var array
+   */
+  private static $caches;
+
+  /**
+   * @param array $criteria
+   *   Ex: ['interface' => 'Civi\Core\HookInterface']
+   * @return string[]
+   *   List of matching classes.
+   */
+  public static function get(array $criteria): array {
+    if (!isset($criteria['interface'])) {
+      throw new \RuntimeException("Malformed request: ClassScanner::get() must specify an interface filter");
+    }
+
+    $cache = static::cache('index');
+    $interface = $criteria['interface'];
+    $interfaceId = md5($interface);
+
+    $knownInterfaces = $cache->get('knownInterfaces');
+    if ($knownInterfaces === NULL) {
+      $knownInterfaces = static::buildIndex($cache);
+      $cache->set('knownInterfaces', $knownInterfaces, static::TTL);
+    }
+    if (!in_array($interface, $knownInterfaces)) {
+      return [];
+    }
+
+    $classes = $cache->get($interfaceId);
+    if ($classes === NULL) {
+      // Some cache backends don't guarantee the completeness of the set.
+      //I suppose this one got purged early. We'll need to rebuild the whole set.
+      $knownInterfaces = static::buildIndex($cache);
+      $cache->set('knownInterfaces', $knownInterfaces, static::TTL);
+      $classes = $cache->get($interfaceId);
+    }
+
+    return static::filterLiveClasses($classes ?: [], $criteria);
+  }
+
+  /**
+   * Fill the 'index' cache with information about all available interfaces.
+   *
+   * Every extant interface will be stored as a separate cache-item.
+   *
+   * Example:
+   *   assert $cache->get(md5(HookInterface::class)) == ['CRM_Foo_Bar', 'Civi\Whiz\Bang']
+   *
+   * @return string[]
+   *   List of PHP interfaces that were detected
+   */
+  private static function buildIndex(\CRM_Utils_Cache_Interface $cache): array {
+    $allClasses = static::scanClasses();
+    $byInterface = [];
+    foreach ($allClasses as $class) {
+      foreach (static::getRelevantInterfaces($class) as $interface) {
+        $byInterface[$interface][] = $class;
+      }
+    }
+
+    $cache->flush();
+    foreach ($byInterface as $interface => $classes) {
+      $cache->set(md5($interface), $classes, static::TTL);
+    }
+
+    return array_keys($byInterface);
+  }
+
+  /**
+   * @return array
+   *   Ex: ['CRM_Foo_Bar', 'Civi\Whiz\Bang']
+   */
+  private static function scanClasses(): array {
+    $classes = static::scanCoreClasses();
+    if (\CRM_Utils_Constant::value('CIVICRM_UF') !== 'UnitTests') {
+      \CRM_Utils_Hook::scanClasses($classes);
+    }
+    return $classes;
+  }
+
+  /**
+   * @return array
+   *   Ex: ['CRM_Foo_Bar', 'Civi\Whiz\Bang']
+   */
+  private static function scanCoreClasses(): array {
+    $cache = static::cache('structure');
+    $cacheKey = 'ClassScanner_core';
+    $classes = $cache->get($cacheKey);
+    if ($classes !== NULL) {
+      return $classes;
+    }
+
+    $civicrmRoot = \Civi::paths()->getPath('[civicrm.root]/');
+
+    // TODO: Consider expanding this search.
+    $classes = [];
+    static::scanFolders($classes, $civicrmRoot, 'Civi/Test/ExampleData', '\\');
+    static::scanFolders($classes, $civicrmRoot, 'CRM/*/WorkflowMessage', '_');
+    static::scanFolders($classes, $civicrmRoot, 'Civi/*/WorkflowMessage', '\\');
+    static::scanFolders($classes, $civicrmRoot, 'Civi/WorkflowMessage', '\\');
+    if (\CRM_Utils_Constant::value('CIVICRM_UF') === 'UnitTests') {
+      static::scanFolders($classes, $civicrmRoot . 'tests/phpunit', 'CRM/*/WorkflowMessage', '_');
+      static::scanFolders($classes, $civicrmRoot . 'tests/phpunit', 'Civi/*/WorkflowMessage', '\\');
+    }
+
+    $cache->set($cacheKey, $classes, static::TTL);
+    return $classes;
+  }
+
+  private static function filterLiveClasses(array $classes, array $criteria): array {
+    return array_filter($classes, function($class) use ($criteria) {
+      if (!class_exists($class)) {
+        return FALSE;
+      }
+      $reflClass = new \ReflectionClass($class);
+      return !$reflClass->isAbstract() && ($reflClass)->implementsInterface($criteria['interface']);
+    });
+  }
+
+  private static function getRelevantInterfaces(string $class): array {
+    $rawInterfaceNames = (new \ReflectionClass($class))->getInterfaceNames();
+    return preg_grep(static::CIVI_INTERFACE_REGEX, $rawInterfaceNames);
+  }
+
+  /**
+   * Search some $classRoot folder for a list of classes.
+   *
+   * Return any classes that implement a Civi-related interface, such as ExampleDataInterface
+   * or HookInterface. (Specifically, interfaces matchinv CIVI_INTERFACE_REGEX.)
+   *
+   * @internal
+   *   Currently reserved for use within civicrm-core. Signature may change.
+   * @param string[] $classes
+   *   List of known/found classes.
+   * @param string $classRoot
+   *   The base folder in which to search.
+   *   Ex: The $civicrm_root or some extension's basedir.
+   * @param string $classDir
+   *   Folder to search (within the $classRoot).
+   *   May use wildcards.
+   *   Ex: "CRM" or "Civi"
+   * @param string $classDelim
+   *   Namespace separator, eg underscore or backslash.
+   */
+  public static function scanFolders(array &$classes, string $classRoot, string $classDir, string $classDelim): void {
+    $classRoot = \CRM_Utils_File::addTrailingSlash($classRoot, '/');
+
+    $baseDirs = (array) glob($classRoot . $classDir);
+    foreach ($baseDirs as $baseDir) {
+      foreach (\CRM_Utils_File::findFiles($baseDir, '*.php') as $absFile) {
+        if (!preg_match(static::CIVI_CLASS_FILE_REGEX, basename($absFile))) {
+          continue;
+        }
+        $absFile = str_replace(DIRECTORY_SEPARATOR, '/', $absFile);
+        $relFile = \CRM_Utils_File::relativize($absFile, $classRoot);
+        $class = str_replace('/', $classDelim, substr($relFile, 0, -4));
+        if (class_exists($class)) {
+          $interfaces = static::getRelevantInterfaces($class);
+          if ($interfaces) {
+            $classes[] = $class;
+          }
+        }
+      }
+    }
+  }
+
+  /**
+   * @param string $name
+   *   - The 'index' cache describes the list of live classes that match an interface. It persists for the
+   *     duration of the system-configuration (eg cleared by system-flush or enable/disable extension).
+   *   - The 'structure' cache describes the class-structure within each extension. It persists for the
+   *     duration of the current page-view and is essentially write-once. This minimizes extra scans during testing.
+   *     (It could almost use Civi::$statics, except we want it to survive throughout testing.)
+   *   - Note: Typical runtime usage should only hit the 'index' cache. The 'structure' cache should only
+   *     be relevant following a system-flush.
+   * @return \CRM_Utils_Cache_Interface
+   * @internal
+   */
+  public static function cache(string $name): \CRM_Utils_Cache_Interface {
+    // Class-scanner runs before container is available. Manage our own cache. (Similar to extension-cache.)
+    // However, unlike extension-cache, we do not want to prefetch all interface lists on all pageloads.
+
+    if (!isset(static::$caches[$name])) {
+      switch ($name) {
+        case 'index':
+          if (empty($_DB_DATAOBJECT['CONFIG'])) {
+            // Atypical example: You have a test with a @dataProvider that relies on ClassScanner. Runs before bot.
+            return new \CRM_Utils_Cache_ArrayCache([]);
+          }
+          static::$caches[$name] = \CRM_Utils_Cache::create([
+            'name' => 'classes',
+            'type' => ['*memory*', 'SqlGroup', 'ArrayCache'],
+            'fastArray' => TRUE,
+          ]);
+
+        case 'structure':
+          static::$caches[$name] = new \CRM_Utils_Cache_ArrayCache([]);
+          break;
+
+      }
+    }
+
+    return static::$caches[$name];
+  }
+
+}