Merge pull request #24117 from civicrm/5.52
[civicrm-core.git] / Civi / Core / ClassScanner.php
CommitLineData
693067e3
TO
1<?php
2/*
3 +--------------------------------------------------------------------+
4 | Copyright CiviCRM LLC. All rights reserved. |
5 | |
6 | This work is published under the GNU AGPLv3 license with some |
7 | permitted exceptions and without any warranty. For full license |
8 | and copyright information, see https://civicrm.org/licensing |
9 +--------------------------------------------------------------------+
10 */
11
12namespace Civi\Core;
13
14/**
15 * The ClassScanner is a helper for finding/loading classes based on their tagged interfaces.
16 *
17 * The implementation of scanning+caching are generally built on these assumptions:
18 *
19 * - Scanning the filesystem can be expensive. One scan should serve many consumers.
20 * - Consumers want to know about specific interfaces (`get(['interface' => 'CRM_Foo_BarInterface'])`.
21 *
22 * We reconcile these goals by performing a single scan and then storing separate cache-items for each
23 * known interface (eg `$cache->get(md5('CRM_Foo_BarInterface'))`).
24 */
25class ClassScanner {
26
27 /**
28 * We cache information about classes that support each interface. Which interfaces should we track?
29 */
30 const CIVI_INTERFACE_REGEX = ';^(CRM_|Civi\\\);';
31
32 /**
33 * We load PHP files to find classes. Which files should we load?
34 */
35 const CIVI_CLASS_FILE_REGEX = '/^([A-Z][A-Za-z0-9]*)\.php$/';
36
37 const TTL = 3 * 24 * 60 * 60;
38
39 /**
40 * @var array
41 */
42 private static $caches;
43
44 /**
45 * @param array $criteria
46 * Ex: ['interface' => 'Civi\Core\HookInterface']
47 * @return string[]
48 * List of matching classes.
49 */
50 public static function get(array $criteria): array {
51 if (!isset($criteria['interface'])) {
52 throw new \RuntimeException("Malformed request: ClassScanner::get() must specify an interface filter");
53 }
54
55 $cache = static::cache('index');
56 $interface = $criteria['interface'];
57 $interfaceId = md5($interface);
58
59 $knownInterfaces = $cache->get('knownInterfaces');
60 if ($knownInterfaces === NULL) {
61 $knownInterfaces = static::buildIndex($cache);
62 $cache->set('knownInterfaces', $knownInterfaces, static::TTL);
63 }
64 if (!in_array($interface, $knownInterfaces)) {
65 return [];
66 }
67
68 $classes = $cache->get($interfaceId);
69 if ($classes === NULL) {
70 // Some cache backends don't guarantee the completeness of the set.
71 //I suppose this one got purged early. We'll need to rebuild the whole set.
72 $knownInterfaces = static::buildIndex($cache);
73 $cache->set('knownInterfaces', $knownInterfaces, static::TTL);
74 $classes = $cache->get($interfaceId);
75 }
76
77 return static::filterLiveClasses($classes ?: [], $criteria);
78 }
79
80 /**
81 * Fill the 'index' cache with information about all available interfaces.
82 *
83 * Every extant interface will be stored as a separate cache-item.
84 *
85 * Example:
86 * assert $cache->get(md5(HookInterface::class)) == ['CRM_Foo_Bar', 'Civi\Whiz\Bang']
87 *
88 * @return string[]
89 * List of PHP interfaces that were detected
90 */
91 private static function buildIndex(\CRM_Utils_Cache_Interface $cache): array {
92 $allClasses = static::scanClasses();
93 $byInterface = [];
94 foreach ($allClasses as $class) {
95 foreach (static::getRelevantInterfaces($class) as $interface) {
96 $byInterface[$interface][] = $class;
97 }
98 }
99
100 $cache->flush();
101 foreach ($byInterface as $interface => $classes) {
102 $cache->set(md5($interface), $classes, static::TTL);
103 }
104
105 return array_keys($byInterface);
106 }
107
108 /**
109 * @return array
110 * Ex: ['CRM_Foo_Bar', 'Civi\Whiz\Bang']
111 */
112 private static function scanClasses(): array {
113 $classes = static::scanCoreClasses();
17e3442e 114 \CRM_Utils_Hook::scanClasses($classes);
693067e3
TO
115 return $classes;
116 }
117
118 /**
119 * @return array
120 * Ex: ['CRM_Foo_Bar', 'Civi\Whiz\Bang']
121 */
122 private static function scanCoreClasses(): array {
123 $cache = static::cache('structure');
124 $cacheKey = 'ClassScanner_core';
125 $classes = $cache->get($cacheKey);
126 if ($classes !== NULL) {
127 return $classes;
128 }
129
130 $civicrmRoot = \Civi::paths()->getPath('[civicrm.root]/');
131
132 // TODO: Consider expanding this search.
133 $classes = [];
134 static::scanFolders($classes, $civicrmRoot, 'Civi/Test/ExampleData', '\\');
135 static::scanFolders($classes, $civicrmRoot, 'CRM/*/WorkflowMessage', '_');
136 static::scanFolders($classes, $civicrmRoot, 'Civi/*/WorkflowMessage', '\\');
137 static::scanFolders($classes, $civicrmRoot, 'Civi/WorkflowMessage', '\\');
3764aead 138 static::scanFolders($classes, $civicrmRoot, 'CRM/*/Import', '_');
693067e3
TO
139 if (\CRM_Utils_Constant::value('CIVICRM_UF') === 'UnitTests') {
140 static::scanFolders($classes, $civicrmRoot . 'tests/phpunit', 'CRM/*/WorkflowMessage', '_');
141 static::scanFolders($classes, $civicrmRoot . 'tests/phpunit', 'Civi/*/WorkflowMessage', '\\');
142 }
143
144 $cache->set($cacheKey, $classes, static::TTL);
145 return $classes;
146 }
147
148 private static function filterLiveClasses(array $classes, array $criteria): array {
149 return array_filter($classes, function($class) use ($criteria) {
150 if (!class_exists($class)) {
151 return FALSE;
152 }
153 $reflClass = new \ReflectionClass($class);
154 return !$reflClass->isAbstract() && ($reflClass)->implementsInterface($criteria['interface']);
155 });
156 }
157
158 private static function getRelevantInterfaces(string $class): array {
159 $rawInterfaceNames = (new \ReflectionClass($class))->getInterfaceNames();
160 return preg_grep(static::CIVI_INTERFACE_REGEX, $rawInterfaceNames);
161 }
162
163 /**
164 * Search some $classRoot folder for a list of classes.
165 *
166 * Return any classes that implement a Civi-related interface, such as ExampleDataInterface
167 * or HookInterface. (Specifically, interfaces matchinv CIVI_INTERFACE_REGEX.)
168 *
169 * @internal
170 * Currently reserved for use within civicrm-core. Signature may change.
171 * @param string[] $classes
172 * List of known/found classes.
173 * @param string $classRoot
174 * The base folder in which to search.
175 * Ex: The $civicrm_root or some extension's basedir.
176 * @param string $classDir
177 * Folder to search (within the $classRoot).
178 * May use wildcards.
179 * Ex: "CRM" or "Civi"
180 * @param string $classDelim
181 * Namespace separator, eg underscore or backslash.
182 */
183 public static function scanFolders(array &$classes, string $classRoot, string $classDir, string $classDelim): void {
184 $classRoot = \CRM_Utils_File::addTrailingSlash($classRoot, '/');
185
186 $baseDirs = (array) glob($classRoot . $classDir);
187 foreach ($baseDirs as $baseDir) {
188 foreach (\CRM_Utils_File::findFiles($baseDir, '*.php') as $absFile) {
189 if (!preg_match(static::CIVI_CLASS_FILE_REGEX, basename($absFile))) {
190 continue;
191 }
192 $absFile = str_replace(DIRECTORY_SEPARATOR, '/', $absFile);
193 $relFile = \CRM_Utils_File::relativize($absFile, $classRoot);
194 $class = str_replace('/', $classDelim, substr($relFile, 0, -4));
195 if (class_exists($class)) {
196 $interfaces = static::getRelevantInterfaces($class);
197 if ($interfaces) {
198 $classes[] = $class;
199 }
200 }
201 }
202 }
203 }
204
205 /**
206 * @param string $name
207 * - The 'index' cache describes the list of live classes that match an interface. It persists for the
208 * duration of the system-configuration (eg cleared by system-flush or enable/disable extension).
209 * - The 'structure' cache describes the class-structure within each extension. It persists for the
210 * duration of the current page-view and is essentially write-once. This minimizes extra scans during testing.
211 * (It could almost use Civi::$statics, except we want it to survive throughout testing.)
212 * - Note: Typical runtime usage should only hit the 'index' cache. The 'structure' cache should only
213 * be relevant following a system-flush.
214 * @return \CRM_Utils_Cache_Interface
215 * @internal
216 */
217 public static function cache(string $name): \CRM_Utils_Cache_Interface {
218 // Class-scanner runs before container is available. Manage our own cache. (Similar to extension-cache.)
219 // However, unlike extension-cache, we do not want to prefetch all interface lists on all pageloads.
220
221 if (!isset(static::$caches[$name])) {
222 switch ($name) {
223 case 'index':
224 if (empty($_DB_DATAOBJECT['CONFIG'])) {
225 // Atypical example: You have a test with a @dataProvider that relies on ClassScanner. Runs before bot.
226 return new \CRM_Utils_Cache_ArrayCache([]);
227 }
228 static::$caches[$name] = \CRM_Utils_Cache::create([
229 'name' => 'classes',
230 'type' => ['*memory*', 'SqlGroup', 'ArrayCache'],
231 'fastArray' => TRUE,
232 ]);
233
234 case 'structure':
235 static::$caches[$name] = new \CRM_Utils_Cache_ArrayCache([]);
236 break;
237
238 }
239 }
240
241 return static::$caches[$name];
242 }
243
244}