Commit | Line | Data |
---|---|---|
6a488035 TO |
1 | <?php |
2 | ||
3 | /** | |
4 | * The ManagedEntities system allows modules to add records to the database | |
5 | * declaratively. Those records will be automatically inserted, updated, | |
6 | * deactivated, and deleted in tandem with their modules. | |
7 | */ | |
8 | class CRM_Core_ManagedEntities { | |
1f103dc4 | 9 | |
4b62bc4f EM |
10 | /** |
11 | * Get clean up options. | |
12 | * | |
13 | * @return array | |
14 | */ | |
1f103dc4 | 15 | public static function getCleanupOptions() { |
be2fb01f | 16 | return [ |
1f103dc4 TO |
17 | 'always' => ts('Always'), |
18 | 'never' => ts('Never'), | |
19 | 'unused' => ts('If Unused'), | |
be2fb01f | 20 | ]; |
1f103dc4 TO |
21 | } |
22 | ||
6a488035 | 23 | /** |
88718db2 TO |
24 | * @var array |
25 | * Array($status => array($name => CRM_Core_Module)). | |
6a488035 | 26 | */ |
e9b95545 | 27 | protected $moduleIndex; |
6a488035 TO |
28 | |
29 | /** | |
88718db2 TO |
30 | * @var array |
31 | * List of all entity declarations. | |
32 | * @see CRM_Utils_Hook::managed() | |
6a488035 | 33 | */ |
e9b95545 | 34 | protected $declarations; |
6a488035 TO |
35 | |
36 | /** | |
d09edf64 | 37 | * Get an instance. |
ba3228d1 EM |
38 | * @param bool $fresh |
39 | * @return \CRM_Core_ManagedEntities | |
6a488035 TO |
40 | */ |
41 | public static function singleton($fresh = FALSE) { | |
42 | static $singleton; | |
43 | if ($fresh || !$singleton) { | |
e9b95545 | 44 | $singleton = new CRM_Core_ManagedEntities(CRM_Core_Module::getAll(), NULL); |
6a488035 TO |
45 | } |
46 | return $singleton; | |
47 | } | |
48 | ||
9c19292b TO |
49 | /** |
50 | * Perform an asynchronous reconciliation when the transaction ends. | |
51 | */ | |
ba3228d1 | 52 | public static function scheduleReconciliation() { |
9c19292b TO |
53 | CRM_Core_Transaction::addCallback( |
54 | CRM_Core_Transaction::PHASE_POST_COMMIT, | |
55 | function () { | |
e70a7fc0 | 56 | CRM_Core_ManagedEntities::singleton(TRUE)->reconcile(); |
9c19292b | 57 | }, |
be2fb01f | 58 | [], |
9c19292b TO |
59 | 'ManagedEntities::reconcile' |
60 | ); | |
61 | } | |
62 | ||
6a488035 | 63 | /** |
6a0b768e TO |
64 | * @param array $modules |
65 | * CRM_Core_Module. | |
66 | * @param array $declarations | |
67 | * Per hook_civicrm_managed. | |
6a488035 TO |
68 | */ |
69 | public function __construct($modules, $declarations) { | |
70 | $this->moduleIndex = self::createModuleIndex($modules); | |
e9b95545 TO |
71 | |
72 | if ($declarations !== NULL) { | |
73 | $this->declarations = self::cleanDeclarations($declarations); | |
0db6c3e1 TO |
74 | } |
75 | else { | |
e9b95545 TO |
76 | $this->declarations = NULL; |
77 | } | |
6a488035 TO |
78 | } |
79 | ||
80 | /** | |
88718db2 | 81 | * Read a managed entity using APIv3. |
378e2654 | 82 | * |
88718db2 TO |
83 | * @param string $moduleName |
84 | * The name of the module which declared entity. | |
85 | * @param string $name | |
86 | * The symbolic name of the entity. | |
72b3a70c CW |
87 | * @return array|NULL |
88 | * API representation, or NULL if the entity does not exist | |
6a488035 TO |
89 | */ |
90 | public function get($moduleName, $name) { | |
91 | $dao = new CRM_Core_DAO_Managed(); | |
92 | $dao->module = $moduleName; | |
93 | $dao->name = $name; | |
94 | if ($dao->find(TRUE)) { | |
be2fb01f | 95 | $params = [ |
6a488035 | 96 | 'id' => $dao->entity_id, |
be2fb01f | 97 | ]; |
bbf66e9c | 98 | $result = NULL; |
637ea2cf E |
99 | try { |
100 | $result = civicrm_api3($dao->entity_type, 'getsingle', $params); | |
101 | } | |
102 | catch (Exception $e) { | |
e9b95545 | 103 | $this->onApiError($dao->entity_type, 'getsingle', $params, $result); |
6a488035 | 104 | } |
637ea2cf | 105 | return $result; |
0db6c3e1 TO |
106 | } |
107 | else { | |
6a488035 TO |
108 | return NULL; |
109 | } | |
110 | } | |
111 | ||
88718db2 TO |
112 | /** |
113 | * Identify any enabled/disabled modules. Add new entities, update | |
114 | * existing entities, and remove orphaned (stale) entities. | |
912511a3 | 115 | * @param bool $ignoreUpgradeMode |
88718db2 TO |
116 | * |
117 | * @throws Exception | |
118 | */ | |
912511a3 SL |
119 | public function reconcile($ignoreUpgradeMode = FALSE) { |
120 | // Do not reconcile whilst we are in upgrade mode | |
121 | if (CRM_Core_Config::singleton()->isUpgradeMode() && !$ignoreUpgradeMode) { | |
122 | return; | |
123 | } | |
e9b95545 | 124 | if ($error = $this->validate($this->getDeclarations())) { |
6a488035 TO |
125 | throw new Exception($error); |
126 | } | |
127 | $this->reconcileEnabledModules(); | |
128 | $this->reconcileDisabledModules(); | |
129 | $this->reconcileUnknownModules(); | |
130 | } | |
131 | ||
88718db2 TO |
132 | /** |
133 | * For all enabled modules, add new entities, update | |
134 | * existing entities, and remove orphaned (stale) entities. | |
135 | * | |
136 | * @throws Exception | |
137 | */ | |
6a488035 TO |
138 | public function reconcileEnabledModules() { |
139 | // Note: any thing currently declared is necessarily from | |
140 | // an active module -- because we got it from a hook! | |
141 | ||
142 | // index by moduleName,name | |
e9b95545 | 143 | $decls = self::createDeclarationIndex($this->moduleIndex, $this->getDeclarations()); |
6a488035 TO |
144 | foreach ($decls as $moduleName => $todos) { |
145 | if (isset($this->moduleIndex[TRUE][$moduleName])) { | |
146 | $this->reconcileEnabledModule($this->moduleIndex[TRUE][$moduleName], $todos); | |
0db6c3e1 TO |
147 | } |
148 | elseif (isset($this->moduleIndex[FALSE][$moduleName])) { | |
6a488035 | 149 | // do nothing -- module should get swept up later |
0db6c3e1 TO |
150 | } |
151 | else { | |
6a488035 TO |
152 | throw new Exception("Entity declaration references invalid or inactive module name [$moduleName]"); |
153 | } | |
154 | } | |
155 | } | |
156 | ||
157 | /** | |
88718db2 TO |
158 | * For one enabled module, add new entities, update existing entities, |
159 | * and remove orphaned (stale) entities. | |
6a488035 | 160 | * |
88718db2 | 161 | * @param \CRM_Core_Module $module |
5a4f6742 | 162 | * @param array $todos |
88718db2 TO |
163 | * List of entities currently declared by this module. |
164 | * array(string $name => array $entityDef). | |
6a488035 TO |
165 | */ |
166 | public function reconcileEnabledModule(CRM_Core_Module $module, $todos) { | |
167 | $dao = new CRM_Core_DAO_Managed(); | |
168 | $dao->module = $module->name; | |
169 | $dao->find(); | |
170 | while ($dao->fetch()) { | |
3bd831aa | 171 | if (isset($todos[$dao->name]) && $todos[$dao->name]) { |
6a488035 | 172 | // update existing entity; remove from $todos |
7cddb4ae | 173 | $this->updateExistingEntity($dao, $todos[$dao->name]); |
6a488035 | 174 | unset($todos[$dao->name]); |
0db6c3e1 TO |
175 | } |
176 | else { | |
6a488035 | 177 | // remove stale entity; not in $todos |
bbf66e9c | 178 | $this->removeStaleEntity($dao); |
6a488035 TO |
179 | } |
180 | } | |
181 | ||
182 | // create new entities from leftover $todos | |
183 | foreach ($todos as $name => $todo) { | |
7cddb4ae | 184 | $this->insertNewEntity($todo); |
6a488035 TO |
185 | } |
186 | } | |
187 | ||
88718db2 TO |
188 | /** |
189 | * For all disabled modules, disable any managed entities. | |
190 | */ | |
6a488035 TO |
191 | public function reconcileDisabledModules() { |
192 | if (empty($this->moduleIndex[FALSE])) { | |
193 | return; | |
194 | } | |
195 | ||
196 | $in = CRM_Core_DAO::escapeStrings(array_keys($this->moduleIndex[FALSE])); | |
197 | $dao = new CRM_Core_DAO_Managed(); | |
198 | $dao->whereAdd("module in ($in)"); | |
04b08baa | 199 | $dao->orderBy('id DESC'); |
6a488035 TO |
200 | $dao->find(); |
201 | while ($dao->fetch()) { | |
7cddb4ae TO |
202 | $this->disableEntity($dao); |
203 | ||
6a488035 TO |
204 | } |
205 | } | |
206 | ||
88718db2 TO |
207 | /** |
208 | * Remove any orphaned (stale) entities that are linked to | |
209 | * unknown modules. | |
210 | */ | |
6a488035 | 211 | public function reconcileUnknownModules() { |
be2fb01f | 212 | $knownModules = []; |
6a488035 TO |
213 | if (array_key_exists(0, $this->moduleIndex) && is_array($this->moduleIndex[0])) { |
214 | $knownModules = array_merge($knownModules, array_keys($this->moduleIndex[0])); | |
215 | } | |
216 | if (array_key_exists(1, $this->moduleIndex) && is_array($this->moduleIndex[1])) { | |
217 | $knownModules = array_merge($knownModules, array_keys($this->moduleIndex[1])); | |
6a488035 TO |
218 | } |
219 | ||
220 | $dao = new CRM_Core_DAO_Managed(); | |
221 | if (!empty($knownModules)) { | |
222 | $in = CRM_Core_DAO::escapeStrings($knownModules); | |
223 | $dao->whereAdd("module NOT IN ($in)"); | |
04b08baa | 224 | $dao->orderBy('id DESC'); |
6a488035 TO |
225 | } |
226 | $dao->find(); | |
227 | while ($dao->fetch()) { | |
bbf66e9c TO |
228 | $this->removeStaleEntity($dao); |
229 | } | |
230 | } | |
6a488035 | 231 | |
7cddb4ae | 232 | /** |
88718db2 | 233 | * Create a new entity. |
7cddb4ae | 234 | * |
6a0b768e TO |
235 | * @param array $todo |
236 | * Entity specification (per hook_civicrm_managedEntities). | |
7cddb4ae TO |
237 | */ |
238 | public function insertNewEntity($todo) { | |
239 | $result = civicrm_api($todo['entity'], 'create', $todo['params']); | |
ef902ada | 240 | if (!empty($result['is_error'])) { |
e9b95545 | 241 | $this->onApiError($todo['entity'], 'create', $todo['params'], $result); |
7cddb4ae TO |
242 | } |
243 | ||
244 | $dao = new CRM_Core_DAO_Managed(); | |
245 | $dao->module = $todo['module']; | |
246 | $dao->name = $todo['name']; | |
247 | $dao->entity_type = $todo['entity']; | |
be76e704 | 248 | // A fatal error will result if there is no valid id but if |
249 | // this is v4 api we might need to access it via ->first(). | |
250 | $dao->entity_id = $result['id'] ?? $result->first()['id']; | |
9c1bc317 | 251 | $dao->cleanup = $todo['cleanup'] ?? NULL; |
7cddb4ae TO |
252 | $dao->save(); |
253 | } | |
254 | ||
255 | /** | |
20429eb9 | 256 | * Update an entity which is believed to exist. |
7cddb4ae TO |
257 | * |
258 | * @param CRM_Core_DAO_Managed $dao | |
6a0b768e TO |
259 | * @param array $todo |
260 | * Entity specification (per hook_civicrm_managedEntities). | |
7cddb4ae TO |
261 | */ |
262 | public function updateExistingEntity($dao, $todo) { | |
0dd54586 TO |
263 | $policy = CRM_Utils_Array::value('update', $todo, 'always'); |
264 | $doUpdate = ($policy == 'always'); | |
265 | ||
266 | if ($doUpdate) { | |
3cf02556 TO |
267 | $defaults = ['id' => $dao->entity_id]; |
268 | if ($this->isActivationSupported($dao->entity_type)) { | |
269 | $defaults['is_active'] = 1; | |
270 | } | |
0dd54586 | 271 | $params = array_merge($defaults, $todo['params']); |
20429eb9 RL |
272 | |
273 | $manager = CRM_Extension_System::singleton()->getManager(); | |
274 | if ($dao->entity_type === 'Job' && !$manager->extensionIsBeingInstalledOrEnabled($dao->module)) { | |
275 | // Special treatment for scheduled jobs: | |
276 | // | |
277 | // If we're being called as part of enabling/installing a module then | |
278 | // we want the default behaviour of setting is_active = 1. | |
279 | // | |
280 | // However, if we're just being called by a normal cache flush then we | |
281 | // should not re-enable a job that an administrator has decided to disable. | |
282 | // | |
283 | // Without this logic there was a problem: site admin might disable | |
284 | // a job, but then when there was a flush op, the job was re-enabled | |
285 | // which can cause significant embarrassment, depending on the job | |
286 | // ("Don't worry, sending mailings is disabled right now..."). | |
287 | unset($params['is_active']); | |
288 | } | |
289 | ||
0dd54586 TO |
290 | $result = civicrm_api($dao->entity_type, 'create', $params); |
291 | if ($result['is_error']) { | |
353ffa53 | 292 | $this->onApiError($dao->entity_type, 'create', $params, $result); |
0dd54586 | 293 | } |
7cddb4ae | 294 | } |
1f103dc4 TO |
295 | |
296 | if (isset($todo['cleanup'])) { | |
297 | $dao->cleanup = $todo['cleanup']; | |
298 | $dao->update(); | |
299 | } | |
7cddb4ae TO |
300 | } |
301 | ||
302 | /** | |
303 | * Update an entity which (a) is believed to exist and which (b) ought to be | |
304 | * inactive. | |
305 | * | |
306 | * @param CRM_Core_DAO_Managed $dao | |
8b91d849 | 307 | * |
308 | * @throws \CiviCRM_API3_Exception | |
7cddb4ae | 309 | */ |
8b91d849 | 310 | public function disableEntity($dao): void { |
fc625166 TO |
311 | $entity_type = $dao->entity_type; |
312 | if ($this->isActivationSupported($entity_type)) { | |
7cddb4ae | 313 | // FIXME cascading for payproc types? |
be2fb01f | 314 | $params = [ |
7cddb4ae TO |
315 | 'version' => 3, |
316 | 'id' => $dao->entity_id, | |
317 | 'is_active' => 0, | |
be2fb01f | 318 | ]; |
7cddb4ae TO |
319 | $result = civicrm_api($dao->entity_type, 'create', $params); |
320 | if ($result['is_error']) { | |
353ffa53 | 321 | $this->onApiError($dao->entity_type, 'create', $params, $result); |
7cddb4ae TO |
322 | } |
323 | } | |
324 | } | |
325 | ||
bbf66e9c | 326 | /** |
88718db2 | 327 | * Remove a stale entity (if policy allows). |
bbf66e9c TO |
328 | * |
329 | * @param CRM_Core_DAO_Managed $dao | |
88718db2 | 330 | * @throws Exception |
bbf66e9c TO |
331 | */ |
332 | public function removeStaleEntity($dao) { | |
1f103dc4 | 333 | $policy = empty($dao->cleanup) ? 'always' : $dao->cleanup; |
378e2654 TO |
334 | switch ($policy) { |
335 | case 'always': | |
336 | $doDelete = TRUE; | |
337 | break; | |
ea100cb5 | 338 | |
378e2654 TO |
339 | case 'never': |
340 | $doDelete = FALSE; | |
341 | break; | |
ea100cb5 | 342 | |
378e2654 | 343 | case 'unused': |
be2fb01f | 344 | $getRefCount = civicrm_api3($dao->entity_type, 'getrefcount', [ |
378e2654 | 345 | 'debug' => 1, |
21dfd5f5 | 346 | 'id' => $dao->entity_id, |
be2fb01f | 347 | ]); |
378e2654 TO |
348 | |
349 | $total = 0; | |
350 | foreach ($getRefCount['values'] as $refCount) { | |
351 | $total += $refCount['count']; | |
352 | } | |
353 | ||
354 | $doDelete = ($total == 0); | |
355 | break; | |
ea100cb5 | 356 | |
378e2654 TO |
357 | default: |
358 | throw new \Exception('Unrecognized cleanup policy: ' . $policy); | |
359 | } | |
bbf66e9c | 360 | |
1f103dc4 | 361 | if ($doDelete) { |
be2fb01f | 362 | $params = [ |
1f103dc4 TO |
363 | 'version' => 3, |
364 | 'id' => $dao->entity_id, | |
be2fb01f | 365 | ]; |
a60c0bc8 SL |
366 | $check = civicrm_api3($dao->entity_type, 'get', $params); |
367 | if ((bool) $check['count']) { | |
368 | $result = civicrm_api($dao->entity_type, 'delete', $params); | |
369 | if ($result['is_error']) { | |
370 | $this->onApiError($dao->entity_type, 'delete', $params, $result); | |
371 | } | |
a60c0bc8 | 372 | } |
be2fb01f CW |
373 | CRM_Core_DAO::executeQuery('DELETE FROM civicrm_managed WHERE id = %1', [ |
374 | 1 => [$dao->id, 'Integer'], | |
375 | ]); | |
1f103dc4 | 376 | } |
6a488035 TO |
377 | } |
378 | ||
2e2605fe EM |
379 | /** |
380 | * Get declarations. | |
381 | * | |
382 | * @return array|null | |
383 | */ | |
e9b95545 TO |
384 | public function getDeclarations() { |
385 | if ($this->declarations === NULL) { | |
be2fb01f | 386 | $this->declarations = []; |
e9b95545 TO |
387 | foreach (CRM_Core_Component::getEnabledComponents() as $component) { |
388 | /** @var CRM_Core_Component_Info $component */ | |
389 | $this->declarations = array_merge($this->declarations, $component->getManagedEntities()); | |
390 | } | |
391 | CRM_Utils_Hook::managed($this->declarations); | |
392 | $this->declarations = self::cleanDeclarations($this->declarations); | |
393 | } | |
394 | return $this->declarations; | |
395 | } | |
396 | ||
6a488035 | 397 | /** |
88718db2 TO |
398 | * @param array $modules |
399 | * Array<CRM_Core_Module>. | |
77b97be7 | 400 | * |
a6c01b45 CW |
401 | * @return array |
402 | * indexed by is_active,name | |
6a488035 TO |
403 | */ |
404 | protected static function createModuleIndex($modules) { | |
be2fb01f | 405 | $result = []; |
6a488035 TO |
406 | foreach ($modules as $module) { |
407 | $result[$module->is_active][$module->name] = $module; | |
408 | } | |
409 | return $result; | |
410 | } | |
411 | ||
412 | /** | |
88718db2 TO |
413 | * @param array $moduleIndex |
414 | * @param array $declarations | |
77b97be7 | 415 | * |
a6c01b45 CW |
416 | * @return array |
417 | * indexed by module,name | |
6a488035 TO |
418 | */ |
419 | protected static function createDeclarationIndex($moduleIndex, $declarations) { | |
be2fb01f | 420 | $result = []; |
6a488035 TO |
421 | if (!isset($moduleIndex[TRUE])) { |
422 | return $result; | |
423 | } | |
424 | foreach ($moduleIndex[TRUE] as $moduleName => $module) { | |
425 | if ($module->is_active) { | |
426 | // need an empty array() for all active modules, even if there are no current $declarations | |
be2fb01f | 427 | $result[$moduleName] = []; |
6a488035 TO |
428 | } |
429 | } | |
430 | foreach ($declarations as $declaration) { | |
431 | $result[$declaration['module']][$declaration['name']] = $declaration; | |
432 | } | |
433 | return $result; | |
434 | } | |
435 | ||
436 | /** | |
fd31fa4c EM |
437 | * @param $declarations |
438 | * | |
72b3a70c CW |
439 | * @return string|bool |
440 | * string on error, or FALSE | |
6a488035 TO |
441 | */ |
442 | protected static function validate($declarations) { | |
443 | foreach ($declarations as $declare) { | |
be2fb01f | 444 | foreach (['name', 'module', 'entity', 'params'] as $key) { |
6a488035 TO |
445 | if (empty($declare[$key])) { |
446 | $str = print_r($declare, TRUE); | |
447 | return ("Managed Entity is missing field \"$key\": $str"); | |
448 | } | |
449 | } | |
450 | // FIXME: validate that each 'module' is known | |
451 | } | |
452 | return FALSE; | |
453 | } | |
454 | ||
a0ee3941 | 455 | /** |
72b3a70c | 456 | * @param array $declarations |
a0ee3941 | 457 | * |
72b3a70c | 458 | * @return array |
a0ee3941 | 459 | */ |
6a488035 TO |
460 | protected static function cleanDeclarations($declarations) { |
461 | foreach ($declarations as $name => &$declare) { | |
462 | if (!array_key_exists('name', $declare)) { | |
463 | $declare['name'] = $name; | |
464 | } | |
465 | } | |
466 | return $declarations; | |
467 | } | |
468 | ||
a0ee3941 | 469 | /** |
e9b95545 TO |
470 | * @param string $entity |
471 | * @param string $action | |
472 | * @param array $params | |
473 | * @param array $result | |
a0ee3941 EM |
474 | * |
475 | * @throws Exception | |
476 | */ | |
e9b95545 | 477 | protected function onApiError($entity, $action, $params, $result) { |
be2fb01f | 478 | CRM_Core_Error::debug_var('ManagedEntities_failed', [ |
e9b95545 TO |
479 | 'entity' => $entity, |
480 | 'action' => $action, | |
6a488035 TO |
481 | 'params' => $params, |
482 | 'result' => $result, | |
be2fb01f | 483 | ]); |
14a5ddb4 | 484 | throw new Exception('API error: ' . $result['error_message'] . ' on ' . $entity . '.' . $action); |
cbb7c7e0 | 485 | } |
96025800 | 486 | |
fc625166 TO |
487 | /** |
488 | * Determine if an entity supports APIv3-based activation/de-activation. | |
489 | * @param string $entity_type | |
490 | * | |
491 | * @return bool | |
492 | * @throws \CiviCRM_API3_Exception | |
493 | */ | |
494 | private function isActivationSupported(string $entity_type): bool { | |
495 | if (!isset(Civi::$statics[__CLASS__][__FUNCTION__][$entity_type])) { | |
496 | $actions = civicrm_api3($entity_type, 'getactions', [])['values']; | |
497 | Civi::$statics[__CLASS__][__FUNCTION__][$entity_type] = FALSE; | |
498 | if (in_array('create', $actions, TRUE) && in_array('getfields', $actions)) { | |
499 | $fields = civicrm_api3($entity_type, 'getfields', ['action' => 'create'])['values']; | |
500 | Civi::$statics[__CLASS__][__FUNCTION__][$entity_type] = array_key_exists('is_active', $fields); | |
501 | } | |
502 | } | |
503 | return Civi::$statics[__CLASS__][__FUNCTION__][$entity_type]; | |
504 | } | |
505 | ||
6a488035 | 506 | } |