Commit | Line | Data |
---|---|---|
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 | ||
12 | namespace 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 | */ | |
25 | class 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(); | |
114 | if (\CRM_Utils_Constant::value('CIVICRM_UF') !== 'UnitTests') { | |
115 | \CRM_Utils_Hook::scanClasses($classes); | |
116 | } | |
117 | return $classes; | |
118 | } | |
119 | ||
120 | /** | |
121 | * @return array | |
122 | * Ex: ['CRM_Foo_Bar', 'Civi\Whiz\Bang'] | |
123 | */ | |
124 | private static function scanCoreClasses(): array { | |
125 | $cache = static::cache('structure'); | |
126 | $cacheKey = 'ClassScanner_core'; | |
127 | $classes = $cache->get($cacheKey); | |
128 | if ($classes !== NULL) { | |
129 | return $classes; | |
130 | } | |
131 | ||
132 | $civicrmRoot = \Civi::paths()->getPath('[civicrm.root]/'); | |
133 | ||
134 | // TODO: Consider expanding this search. | |
135 | $classes = []; | |
136 | static::scanFolders($classes, $civicrmRoot, 'Civi/Test/ExampleData', '\\'); | |
137 | static::scanFolders($classes, $civicrmRoot, 'CRM/*/WorkflowMessage', '_'); | |
138 | static::scanFolders($classes, $civicrmRoot, 'Civi/*/WorkflowMessage', '\\'); | |
139 | static::scanFolders($classes, $civicrmRoot, 'Civi/WorkflowMessage', '\\'); | |
140 | if (\CRM_Utils_Constant::value('CIVICRM_UF') === 'UnitTests') { | |
141 | static::scanFolders($classes, $civicrmRoot . 'tests/phpunit', 'CRM/*/WorkflowMessage', '_'); | |
142 | static::scanFolders($classes, $civicrmRoot . 'tests/phpunit', 'Civi/*/WorkflowMessage', '\\'); | |
143 | } | |
144 | ||
145 | $cache->set($cacheKey, $classes, static::TTL); | |
146 | return $classes; | |
147 | } | |
148 | ||
149 | private static function filterLiveClasses(array $classes, array $criteria): array { | |
150 | return array_filter($classes, function($class) use ($criteria) { | |
151 | if (!class_exists($class)) { | |
152 | return FALSE; | |
153 | } | |
154 | $reflClass = new \ReflectionClass($class); | |
155 | return !$reflClass->isAbstract() && ($reflClass)->implementsInterface($criteria['interface']); | |
156 | }); | |
157 | } | |
158 | ||
159 | private static function getRelevantInterfaces(string $class): array { | |
160 | $rawInterfaceNames = (new \ReflectionClass($class))->getInterfaceNames(); | |
161 | return preg_grep(static::CIVI_INTERFACE_REGEX, $rawInterfaceNames); | |
162 | } | |
163 | ||
164 | /** | |
165 | * Search some $classRoot folder for a list of classes. | |
166 | * | |
167 | * Return any classes that implement a Civi-related interface, such as ExampleDataInterface | |
168 | * or HookInterface. (Specifically, interfaces matchinv CIVI_INTERFACE_REGEX.) | |
169 | * | |
170 | * @internal | |
171 | * Currently reserved for use within civicrm-core. Signature may change. | |
172 | * @param string[] $classes | |
173 | * List of known/found classes. | |
174 | * @param string $classRoot | |
175 | * The base folder in which to search. | |
176 | * Ex: The $civicrm_root or some extension's basedir. | |
177 | * @param string $classDir | |
178 | * Folder to search (within the $classRoot). | |
179 | * May use wildcards. | |
180 | * Ex: "CRM" or "Civi" | |
181 | * @param string $classDelim | |
182 | * Namespace separator, eg underscore or backslash. | |
183 | */ | |
184 | public static function scanFolders(array &$classes, string $classRoot, string $classDir, string $classDelim): void { | |
185 | $classRoot = \CRM_Utils_File::addTrailingSlash($classRoot, '/'); | |
186 | ||
187 | $baseDirs = (array) glob($classRoot . $classDir); | |
188 | foreach ($baseDirs as $baseDir) { | |
189 | foreach (\CRM_Utils_File::findFiles($baseDir, '*.php') as $absFile) { | |
190 | if (!preg_match(static::CIVI_CLASS_FILE_REGEX, basename($absFile))) { | |
191 | continue; | |
192 | } | |
193 | $absFile = str_replace(DIRECTORY_SEPARATOR, '/', $absFile); | |
194 | $relFile = \CRM_Utils_File::relativize($absFile, $classRoot); | |
195 | $class = str_replace('/', $classDelim, substr($relFile, 0, -4)); | |
196 | if (class_exists($class)) { | |
197 | $interfaces = static::getRelevantInterfaces($class); | |
198 | if ($interfaces) { | |
199 | $classes[] = $class; | |
200 | } | |
201 | } | |
202 | } | |
203 | } | |
204 | } | |
205 | ||
206 | /** | |
207 | * @param string $name | |
208 | * - The 'index' cache describes the list of live classes that match an interface. It persists for the | |
209 | * duration of the system-configuration (eg cleared by system-flush or enable/disable extension). | |
210 | * - The 'structure' cache describes the class-structure within each extension. It persists for the | |
211 | * duration of the current page-view and is essentially write-once. This minimizes extra scans during testing. | |
212 | * (It could almost use Civi::$statics, except we want it to survive throughout testing.) | |
213 | * - Note: Typical runtime usage should only hit the 'index' cache. The 'structure' cache should only | |
214 | * be relevant following a system-flush. | |
215 | * @return \CRM_Utils_Cache_Interface | |
216 | * @internal | |
217 | */ | |
218 | public static function cache(string $name): \CRM_Utils_Cache_Interface { | |
219 | // Class-scanner runs before container is available. Manage our own cache. (Similar to extension-cache.) | |
220 | // However, unlike extension-cache, we do not want to prefetch all interface lists on all pageloads. | |
221 | ||
222 | if (!isset(static::$caches[$name])) { | |
223 | switch ($name) { | |
224 | case 'index': | |
225 | if (empty($_DB_DATAOBJECT['CONFIG'])) { | |
226 | // Atypical example: You have a test with a @dataProvider that relies on ClassScanner. Runs before bot. | |
227 | return new \CRM_Utils_Cache_ArrayCache([]); | |
228 | } | |
229 | static::$caches[$name] = \CRM_Utils_Cache::create([ | |
230 | 'name' => 'classes', | |
231 | 'type' => ['*memory*', 'SqlGroup', 'ArrayCache'], | |
232 | 'fastArray' => TRUE, | |
233 | ]); | |
234 | ||
235 | case 'structure': | |
236 | static::$caches[$name] = new \CRM_Utils_Cache_ArrayCache([]); | |
237 | break; | |
238 | ||
239 | } | |
240 | } | |
241 | ||
242 | return static::$caches[$name]; | |
243 | } | |
244 | ||
245 | } |