Skip to content

Commit

Permalink
Merge pull request #23854 from totten/master-mixin-wfmsg
Browse files Browse the repository at this point in the history
Scan for classes based on the PHP interface (WorkflowMessageInterface, ExampleDataInterface)
  • Loading branch information
eileenmcnaughton authored Jun 28, 2022
2 parents a80402b + 652a831 commit b03e69b
Show file tree
Hide file tree
Showing 22 changed files with 409 additions and 71 deletions.
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];
}

}
File renamed without changes.
File renamed without changes.
Loading

0 comments on commit b03e69b

Please sign in to comment.