From 693067e365915ce280217047009c9e87d70d0719 Mon Sep 17 00:00:00 2001 From: Tim Otten Date: Tue, 21 Jun 2022 02:11:44 -0700 Subject: [PATCH] ClassScanner - Add utility for scanning classes based on PHP interfaces --- CRM/Extension/Manager/Module.php | 9 ++ CRM/Utils/Hook.php | 16 ++ CRM/Utils/System.php | 1 + Civi/Core/ClassScanner.php | 245 +++++++++++++++++++++++++++++++ 4 files changed, 271 insertions(+) create mode 100644 Civi/Core/ClassScanner.php diff --git a/CRM/Extension/Manager/Module.php b/CRM/Extension/Manager/Module.php index 4dc170aaf3..b8d385cd59 100644 --- a/CRM/Extension/Manager/Module.php +++ b/CRM/Extension/Manager/Module.php @@ -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 */ diff --git a/CRM/Utils/Hook.php b/CRM/Utils/Hook.php index c44273c842..b3a20a312f 100644 --- a/CRM/Utils/Hook.php +++ b/CRM/Utils/Hook.php @@ -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 diff --git a/CRM/Utils/System.php b/CRM/Utils/System.php index 757a1c4070..d92b359d7e 100644 --- a/CRM/Utils/System.php +++ b/CRM/Utils/System.php @@ -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 index 0000000000..d1b3523616 --- /dev/null +++ b/Civi/Core/ClassScanner.php @@ -0,0 +1,245 @@ + '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]; + } + +} -- 2.25.1