Skip to content
New issue

Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.

By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.

Already on GitHub? Sign in to your account

Scan for classes based on the PHP interface (WorkflowMessageInterface, ExampleDataInterface) #23854

Merged
merged 7 commits into from
Jun 28, 2022
9 changes: 9 additions & 0 deletions CRM/Extension/Manager/Module.php
Original file line number Diff line number Diff line change
Expand Up @@ -97,6 +97,15 @@ public function onPreEnable(CRM_Extension_Info $info) {
$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
*/
Expand Down
16 changes: 16 additions & 0 deletions CRM/Utils/Hook.php
Original file line number Diff line number Diff line change
Expand Up @@ -1676,6 +1676,22 @@ public static function buildUFGroupsForModule($moduleName, &$ufGroups) {
);
}

/**
* (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
Expand Down
9 changes: 2 additions & 7 deletions CRM/Utils/System.php
Original file line number Diff line number Diff line change
Expand Up @@ -1487,13 +1487,7 @@ public static function flushCache() {
// a bit aggressive, but livable for now
CRM_Utils_Cache::singleton()->flush();

// Traditionally, systems running on memory-backed caches were quite
// zealous about destroying *all* memory-backed caches during a flush().
// These flushes simulate that legacy behavior. However, they should probably
// be removed at some point.
$localDrivers = ['CRM_Utils_Cache_ArrayCache', 'CRM_Utils_Cache_NoCache'];
if (Civi\Core\Container::isContainerBooted()
&& !in_array(get_class(CRM_Utils_Cache::singleton()), $localDrivers)) {
if (Civi\Core\Container::isContainerBooted()) {
Civi::cache('long')->flush();
Civi::cache('settings')->flush();
Civi::cache('js_strings')->flush();
Expand All @@ -1503,6 +1497,7 @@ public static function flushCache() {
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();
}
Expand Down
2 changes: 1 addition & 1 deletion Civi/Api4/Action/ExampleData/Get.php
Original file line number Diff line number Diff line change
Expand Up @@ -19,7 +19,7 @@
/**
* Get a list of example data-sets.
*
* Examples are generated by scanning `*.ex.php` files. The scanner caches
* Examples are generated by scanning `ExampleDataInterface` files. The scanner caches
* metadata fields (`name`, `title`, `tags`, `file`) to avoid extraneous scanning, but
* substantive fields (`data`) are computed as-needed.
*
Expand Down
245 changes: 245 additions & 0 deletions Civi/Core/ClassScanner.php
Original file line number Diff line number Diff line change
@@ -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];
}

}
Loading