Merge pull request #22888 from civicrm/5.47
[civicrm-core.git] / CRM / Core / ManagedEntities.php
1 <?php
2
3 use Civi\Api4\Managed;
4
5 /**
6 * The ManagedEntities system allows modules to add records to the database
7 * declaratively. Those records will be automatically inserted, updated,
8 * deactivated, and deleted in tandem with their modules.
9 */
10 class CRM_Core_ManagedEntities {
11
12 /**
13 * Get clean up options.
14 *
15 * @return array
16 */
17 public static function getCleanupOptions() {
18 return [
19 'always' => ts('Always'),
20 'never' => ts('Never'),
21 'unused' => ts('If Unused'),
22 ];
23 }
24
25 /**
26 * @var array
27 * Array($status => array($name => CRM_Core_Module)).
28 */
29 protected $moduleIndex;
30
31 /**
32 * Actions arising from the managed entities.
33 *
34 * @var array
35 */
36 protected $managedActions = [];
37
38 /**
39 * @var array
40 * List of all entity declarations.
41 * @see CRM_Utils_Hook::managed()
42 */
43 protected $declarations;
44
45 /**
46 * Get an instance.
47 * @param bool $fresh
48 * @return \CRM_Core_ManagedEntities
49 */
50 public static function singleton($fresh = FALSE) {
51 static $singleton;
52 if ($fresh || !$singleton) {
53 $singleton = new CRM_Core_ManagedEntities(CRM_Core_Module::getAll());
54 }
55 return $singleton;
56 }
57
58 /**
59 * Perform an asynchronous reconciliation when the transaction ends.
60 */
61 public static function scheduleReconciliation() {
62 CRM_Core_Transaction::addCallback(
63 CRM_Core_Transaction::PHASE_POST_COMMIT,
64 function () {
65 CRM_Core_ManagedEntities::singleton(TRUE)->reconcile();
66 },
67 [],
68 'ManagedEntities::reconcile'
69 );
70 }
71
72 /**
73 * @param array $modules
74 * CRM_Core_Module.
75 */
76 public function __construct(array $modules) {
77 $this->moduleIndex = $this->createModuleIndex($modules);
78 }
79
80 /**
81 * Read a managed entity using APIv3.
82 *
83 * @deprecated
84 *
85 * @param string $moduleName
86 * The name of the module which declared entity.
87 * @param string $name
88 * The symbolic name of the entity.
89 * @return array|NULL
90 * API representation, or NULL if the entity does not exist
91 */
92 public function get($moduleName, $name) {
93 $dao = new CRM_Core_DAO_Managed();
94 $dao->module = $moduleName;
95 $dao->name = $name;
96 if ($dao->find(TRUE)) {
97 $params = [
98 'id' => $dao->entity_id,
99 ];
100 $result = NULL;
101 try {
102 $result = civicrm_api3($dao->entity_type, 'getsingle', $params);
103 }
104 catch (Exception $e) {
105 $this->onApiError($dao->entity_type, 'getsingle', $params, $result);
106 }
107 return $result;
108 }
109 else {
110 return NULL;
111 }
112 }
113
114 /**
115 * Identify any enabled/disabled modules. Add new entities, update
116 * existing entities, and remove orphaned (stale) entities.
117 *
118 * @param bool $ignoreUpgradeMode
119 * Unused.
120 * @throws \CRM_Core_Exception
121 */
122 public function reconcile($ignoreUpgradeMode = FALSE) {
123 $this->loadDeclarations();
124 if ($error = $this->validate($this->getDeclarations())) {
125 throw new CRM_Core_Exception($error);
126 }
127 $this->loadManagedEntityActions();
128 $this->reconcileEnabledModules();
129 $this->reconcileDisabledModules();
130 $this->reconcileUnknownModules();
131 }
132
133 /**
134 * Force-revert a record back to its original state.
135 * @param array $params
136 * Key->value properties of CRM_Core_DAO_Managed used to match an existing record
137 */
138 public function revert(array $params) {
139 $mgd = new \CRM_Core_DAO_Managed();
140 $mgd->copyValues($params);
141 $mgd->find(TRUE);
142 $this->loadDeclarations();
143 $declarations = CRM_Utils_Array::findAll($this->declarations, [
144 'module' => $mgd->module,
145 'name' => $mgd->name,
146 'entity' => $mgd->entity_type,
147 ]);
148 if ($mgd->id && isset($declarations[0])) {
149 $this->updateExistingEntity($mgd, ['update' => 'always'] + $declarations[0]);
150 return TRUE;
151 }
152 return FALSE;
153 }
154
155 /**
156 * For all enabled modules, add new entities, update
157 * existing entities, and remove orphaned (stale) entities.
158 */
159 protected function reconcileEnabledModules(): void {
160 // Note: any thing currently declared is necessarily from
161 // an active module -- because we got it from a hook!
162
163 // index by moduleName,name
164 $decls = $this->createDeclarationIndex($this->moduleIndex, $this->getDeclarations());
165 foreach ($decls as $moduleName => $todos) {
166 if ($this->isModuleEnabled($moduleName)) {
167 $this->reconcileEnabledModule($moduleName);
168 }
169 }
170 }
171
172 /**
173 * For one enabled module, add new entities, update existing entities,
174 * and remove orphaned (stale) entities.
175 *
176 * @param string $module
177 */
178 protected function reconcileEnabledModule(string $module): void {
179 foreach ($this->getManagedEntitiesToUpdate(['module' => $module]) as $todo) {
180 $dao = new CRM_Core_DAO_Managed();
181 $dao->module = $todo['module'];
182 $dao->name = $todo['name'];
183 $dao->entity_type = $todo['entity_type'];
184 $dao->entity_id = $todo['entity_id'];
185 $dao->entity_modified_date = $todo['entity_modified_date'];
186 $dao->id = $todo['id'];
187 $this->updateExistingEntity($dao, $todo);
188 }
189
190 foreach ($this->getManagedEntitiesToDelete(['module' => $module]) as $todo) {
191 $dao = new CRM_Core_DAO_Managed();
192 $dao->module = $todo['module'];
193 $dao->name = $todo['name'];
194 $dao->entity_type = $todo['entity_type'];
195 $dao->id = $todo['id'];
196 $dao->cleanup = $todo['cleanup'];
197 $dao->entity_id = $todo['entity_id'];
198 $this->removeStaleEntity($dao);
199 }
200 foreach ($this->getManagedEntitiesToCreate(['module' => $module]) as $todo) {
201 $this->insertNewEntity($todo);
202 }
203 }
204
205 /**
206 * Get the managed entities to be created.
207 *
208 * @param array $filters
209 *
210 * @return array
211 */
212 protected function getManagedEntitiesToCreate(array $filters = []): array {
213 return $this->getManagedEntities(array_merge($filters, ['managed_action' => 'create']));
214 }
215
216 /**
217 * Get the managed entities to be updated.
218 *
219 * @param array $filters
220 *
221 * @return array
222 */
223 protected function getManagedEntitiesToUpdate(array $filters = []): array {
224 return $this->getManagedEntities(array_merge($filters, ['managed_action' => 'update']));
225 }
226
227 /**
228 * Get the managed entities to be deleted.
229 *
230 * @param array $filters
231 *
232 * @return array
233 */
234 protected function getManagedEntitiesToDelete(array $filters = []): array {
235 // Return array in reverse-order so that child entities are cleaned up before their parents
236 return array_reverse($this->getManagedEntities(array_merge($filters, ['managed_action' => 'delete'])));
237 }
238
239 /**
240 * Get the managed entities that fit the criteria.
241 *
242 * @param array $filters
243 *
244 * @return array
245 */
246 protected function getManagedEntities(array $filters = []): array {
247 $return = [];
248 foreach ($this->managedActions as $actionKey => $action) {
249 foreach ($filters as $filterKey => $filterValue) {
250 if ($action[$filterKey] !== $filterValue) {
251 continue 2;
252 }
253 }
254 $return[$actionKey] = $action;
255 }
256 return $return;
257 }
258
259 /**
260 * For all disabled modules, disable any managed entities.
261 */
262 protected function reconcileDisabledModules() {
263 if (empty($this->moduleIndex[FALSE])) {
264 return;
265 }
266
267 $in = CRM_Core_DAO::escapeStrings(array_keys($this->moduleIndex[FALSE]));
268 $dao = new CRM_Core_DAO_Managed();
269 $dao->whereAdd("module in ($in)");
270 $dao->orderBy('id DESC');
271 $dao->find();
272 while ($dao->fetch()) {
273 $this->disableEntity($dao);
274
275 }
276 }
277
278 /**
279 * Remove any orphaned (stale) entities that are linked to
280 * unknown modules.
281 */
282 protected function reconcileUnknownModules() {
283 $knownModules = [];
284 if (array_key_exists(0, $this->moduleIndex) && is_array($this->moduleIndex[0])) {
285 $knownModules = array_merge($knownModules, array_keys($this->moduleIndex[0]));
286 }
287 if (array_key_exists(1, $this->moduleIndex) && is_array($this->moduleIndex[1])) {
288 $knownModules = array_merge($knownModules, array_keys($this->moduleIndex[1]));
289 }
290
291 $dao = new CRM_Core_DAO_Managed();
292 if (!empty($knownModules)) {
293 $in = CRM_Core_DAO::escapeStrings($knownModules);
294 $dao->whereAdd("module NOT IN ($in)");
295 $dao->orderBy('id DESC');
296 }
297 $dao->find();
298 while ($dao->fetch()) {
299 $this->removeStaleEntity($dao);
300 }
301 }
302
303 /**
304 * Create a new entity.
305 *
306 * @param array $todo
307 * Entity specification (per hook_civicrm_managedEntities).
308 */
309 protected function insertNewEntity($todo) {
310 if ($todo['params']['version'] == 4) {
311 $todo['params']['checkPermissions'] = FALSE;
312 }
313
314 $result = civicrm_api($todo['entity_type'], 'create', ['debug' => TRUE] + $todo['params']);
315 if (!empty($result['is_error'])) {
316 $this->onApiError($todo['entity_type'], 'create', $todo['params'], $result);
317 }
318
319 $dao = new CRM_Core_DAO_Managed();
320 $dao->module = $todo['module'];
321 $dao->name = $todo['name'];
322 $dao->entity_type = $todo['entity_type'];
323 // A fatal error will result if there is no valid id but if
324 // this is v4 api we might need to access it via ->first().
325 $dao->entity_id = $result['id'] ?? $result->first()['id'];
326 $dao->cleanup = $todo['cleanup'] ?? NULL;
327 $dao->save();
328 }
329
330 /**
331 * Update an entity which is believed to exist.
332 *
333 * @param CRM_Core_DAO_Managed $dao
334 * @param array $todo
335 * Entity specification (per hook_civicrm_managedEntities).
336 */
337 protected function updateExistingEntity($dao, $todo) {
338 $policy = $todo['update'] ?? 'always';
339 $doUpdate = ($policy === 'always');
340
341 if ($policy === 'unmodified') {
342 // If this is not an APIv4 managed entity, the entity_modidfied_date will always be null
343 if (!CRM_Core_BAO_Managed::isApi4ManagedType($dao->entity_type)) {
344 Civi::log()->warning('ManagedEntity update policy "unmodified" specified for entity type ' . $dao->entity_type . ' which is not an APIv4 ManagedEntity. Falling back to policy "always".');
345 }
346 $doUpdate = empty($dao->entity_modified_date);
347 }
348
349 if ($doUpdate && $todo['params']['version'] == 3) {
350 $defaults = ['id' => $dao->entity_id];
351 if ($this->isActivationSupported($dao->entity_type)) {
352 $defaults['is_active'] = 1;
353 }
354 $params = array_merge($defaults, $todo['params']);
355
356 $manager = CRM_Extension_System::singleton()->getManager();
357 if ($dao->entity_type === 'Job' && !$manager->extensionIsBeingInstalledOrEnabled($dao->module)) {
358 // Special treatment for scheduled jobs:
359 //
360 // If we're being called as part of enabling/installing a module then
361 // we want the default behaviour of setting is_active = 1.
362 //
363 // However, if we're just being called by a normal cache flush then we
364 // should not re-enable a job that an administrator has decided to disable.
365 //
366 // Without this logic there was a problem: site admin might disable
367 // a job, but then when there was a flush op, the job was re-enabled
368 // which can cause significant embarrassment, depending on the job
369 // ("Don't worry, sending mailings is disabled right now...").
370 unset($params['is_active']);
371 }
372
373 $result = civicrm_api($dao->entity_type, 'create', $params);
374 if ($result['is_error']) {
375 $this->onApiError($dao->entity_type, 'create', $params, $result);
376 }
377 }
378 elseif ($doUpdate && $todo['params']['version'] == 4) {
379 $params = ['checkPermissions' => FALSE] + $todo['params'];
380 $params['values']['id'] = $dao->entity_id;
381 civicrm_api4($dao->entity_type, 'update', $params);
382 }
383
384 if (isset($todo['cleanup']) || $doUpdate) {
385 $dao->cleanup = $todo['cleanup'] ?? NULL;
386 // Reset the `entity_modified_date` timestamp if reverting record.
387 $dao->entity_modified_date = $doUpdate ? 'null' : NULL;
388 $dao->update();
389 }
390 }
391
392 /**
393 * Update an entity which (a) is believed to exist and which (b) ought to be
394 * inactive.
395 *
396 * @param CRM_Core_DAO_Managed $dao
397 *
398 * @throws \CiviCRM_API3_Exception
399 */
400 protected function disableEntity($dao): void {
401 $entity_type = $dao->entity_type;
402 if ($this->isActivationSupported($entity_type)) {
403 // FIXME cascading for payproc types?
404 $params = [
405 'version' => 3,
406 'id' => $dao->entity_id,
407 'is_active' => 0,
408 ];
409 $result = civicrm_api($dao->entity_type, 'create', $params);
410 if ($result['is_error']) {
411 $this->onApiError($dao->entity_type, 'create', $params, $result);
412 }
413 // Reset the `entity_modified_date` timestamp to indicate that the entity has not been modified by the user.
414 $dao->entity_modified_date = 'null';
415 $dao->update();
416 }
417 }
418
419 /**
420 * Remove a stale entity (if policy allows).
421 *
422 * @param CRM_Core_DAO_Managed $dao
423 * @throws CRM_Core_Exception
424 */
425 protected function removeStaleEntity($dao) {
426 $policy = empty($dao->cleanup) ? 'always' : $dao->cleanup;
427 switch ($policy) {
428 case 'always':
429 $doDelete = TRUE;
430 break;
431
432 case 'never':
433 $doDelete = FALSE;
434 break;
435
436 case 'unused':
437 if (CRM_Core_BAO_Managed::isApi4ManagedType($dao->entity_type)) {
438 $getRefCount = \Civi\Api4\Utils\CoreUtil::getRefCount($dao->entity_type, $dao->entity_id);
439 }
440 else {
441 $getRefCount = civicrm_api3($dao->entity_type, 'getrefcount', [
442 'id' => $dao->entity_id,
443 ])['values'];
444 }
445
446 // FIXME: This extra counting should be unnecessary, because getRefCount only returns values if count > 0
447 $total = 0;
448 foreach ($getRefCount as $refCount) {
449 $total += $refCount['count'];
450 }
451
452 $doDelete = ($total == 0);
453 break;
454
455 default:
456 throw new CRM_Core_Exception('Unrecognized cleanup policy: ' . $policy);
457 }
458
459 // APIv4 delete - deletion from `civicrm_managed` will be taken care of by
460 // CRM_Core_BAO_Managed::on_hook_civicrm_post()
461 if ($doDelete && CRM_Core_BAO_Managed::isApi4ManagedType($dao->entity_type)) {
462 civicrm_api4($dao->entity_type, 'delete', [
463 'checkPermissions' => FALSE,
464 'where' => [['id', '=', $dao->entity_id]],
465 ]);
466 }
467 // APIv3 delete
468 elseif ($doDelete) {
469 $params = [
470 'version' => 3,
471 'id' => $dao->entity_id,
472 ];
473 $check = civicrm_api3($dao->entity_type, 'get', $params);
474 if ($check['count']) {
475 $result = civicrm_api($dao->entity_type, 'delete', $params);
476 if ($result['is_error']) {
477 if (isset($dao->name)) {
478 $params['name'] = $dao->name;
479 }
480 $this->onApiError($dao->entity_type, 'delete', $params, $result);
481 }
482 }
483 CRM_Core_DAO::executeQuery('DELETE FROM civicrm_managed WHERE id = %1', [
484 1 => [$dao->id, 'Integer'],
485 ]);
486 }
487 }
488
489 /**
490 * Get declarations.
491 *
492 * @return array|null
493 */
494 protected function getDeclarations() {
495 return $this->declarations;
496 }
497
498 /**
499 * @param array $modules
500 * Array<CRM_Core_Module>.
501 *
502 * @return array
503 * indexed by is_active,name
504 */
505 protected function createModuleIndex($modules) {
506 $result = [];
507 foreach ($modules as $module) {
508 $result[$module->is_active][$module->name] = $module;
509 }
510 return $result;
511 }
512
513 /**
514 * @param array $moduleIndex
515 * @param array $declarations
516 *
517 * @return array
518 * indexed by module,name
519 */
520 protected function createDeclarationIndex($moduleIndex, $declarations) {
521 $result = [];
522 if (!isset($moduleIndex[TRUE])) {
523 return $result;
524 }
525 foreach ($moduleIndex[TRUE] as $moduleName => $module) {
526 if ($module->is_active) {
527 // need an empty array() for all active modules, even if there are no current $declarations
528 $result[$moduleName] = [];
529 }
530 }
531 foreach ($declarations as $declaration) {
532 $result[$declaration['module']][$declaration['name']] = $declaration;
533 }
534 return $result;
535 }
536
537 /**
538 * @param array $declarations
539 *
540 * @return string|bool
541 * string on error, or FALSE
542 */
543 protected function validate($declarations) {
544 foreach ($declarations as $module => $declare) {
545 foreach (['name', 'module', 'entity', 'params'] as $key) {
546 if (empty($declare[$key])) {
547 $str = print_r($declare, TRUE);
548 return ts('Managed Entity (%1) is missing field "%2": %3', [$module, $key, $str]);
549 }
550 }
551 if (!$this->isModuleRecognised($declare['module'])) {
552 return ts('Entity declaration references invalid or inactive module name [%1]', [$declare['module']]);
553 }
554 }
555 return FALSE;
556 }
557
558 /**
559 * Is the module recognised (as an enabled or disabled extension in the system).
560 *
561 * @param string $module
562 *
563 * @return bool
564 */
565 protected function isModuleRecognised(string $module): bool {
566 return $this->isModuleDisabled($module) || $this->isModuleEnabled($module);
567 }
568
569 /**
570 * Is the module enabled.
571 *
572 * @param string $module
573 *
574 * @return bool
575 */
576 protected function isModuleEnabled(string $module): bool {
577 return isset($this->moduleIndex[TRUE][$module]);
578 }
579
580 /**
581 * Is the module disabled.
582 *
583 * @param string $module
584 *
585 * @return bool
586 */
587 protected function isModuleDisabled(string $module): bool {
588 return isset($this->moduleIndex[FALSE][$module]);
589 }
590
591 /**
592 * @param array $declarations
593 *
594 * @return array
595 */
596 protected function cleanDeclarations(array $declarations): array {
597 foreach ($declarations as $name => &$declare) {
598 if (!array_key_exists('name', $declare)) {
599 $declare['name'] = $name;
600 }
601 }
602 return $declarations;
603 }
604
605 /**
606 * @param string $entity
607 * @param string $action
608 * @param array $params
609 * @param array $result
610 *
611 * @throws Exception
612 */
613 protected function onApiError($entity, $action, $params, $result) {
614 CRM_Core_Error::debug_var('ManagedEntities_failed', [
615 'entity' => $entity,
616 'action' => $action,
617 'params' => $params,
618 'result' => $result,
619 ]);
620 throw new Exception('API error: ' . $result['error_message'] . ' on ' . $entity . '.' . $action
621 . (!empty($params['name']) ? '( entity name ' . $params['name'] . ')' : '')
622 );
623 }
624
625 /**
626 * Determine if an entity supports APIv3-based activation/de-activation.
627 * @param string $entity_type
628 *
629 * @return bool
630 * @throws \CiviCRM_API3_Exception
631 */
632 private function isActivationSupported(string $entity_type): bool {
633 if (!isset(Civi::$statics[__CLASS__][__FUNCTION__][$entity_type])) {
634 $actions = civicrm_api3($entity_type, 'getactions', [])['values'];
635 Civi::$statics[__CLASS__][__FUNCTION__][$entity_type] = FALSE;
636 if (in_array('create', $actions, TRUE) && in_array('getfields', $actions)) {
637 $fields = civicrm_api3($entity_type, 'getfields', ['action' => 'create'])['values'];
638 Civi::$statics[__CLASS__][__FUNCTION__][$entity_type] = array_key_exists('is_active', $fields);
639 }
640 }
641 return Civi::$statics[__CLASS__][__FUNCTION__][$entity_type];
642 }
643
644 /**
645 * Load declarations into the class property.
646 *
647 * This picks it up from hooks and enabled components.
648 */
649 protected function loadDeclarations(): void {
650 $this->declarations = [];
651 foreach (CRM_Core_Component::getEnabledComponents() as $component) {
652 $this->declarations = array_merge($this->declarations, $component->getManagedEntities());
653 }
654 CRM_Utils_Hook::managed($this->declarations);
655 $this->declarations = $this->cleanDeclarations($this->declarations);
656 }
657
658 protected function loadManagedEntityActions(): void {
659 $managedEntities = Managed::get(FALSE)->addSelect('*')->execute();
660 foreach ($managedEntities as $managedEntity) {
661 $key = "{$managedEntity['module']}_{$managedEntity['name']}_{$managedEntity['entity_type']}";
662 // Set to 'delete' - it will be overwritten below if it is to be updated.
663 $action = 'delete';
664 $this->managedActions[$key] = array_merge($managedEntity, ['managed_action' => $action]);
665 }
666 foreach ($this->declarations as $declaration) {
667 $key = "{$declaration['module']}_{$declaration['name']}_{$declaration['entity']}";
668 if (isset($this->managedActions[$key])) {
669 $this->managedActions[$key]['params'] = $declaration['params'];
670 $this->managedActions[$key]['managed_action'] = 'update';
671 $this->managedActions[$key]['cleanup'] = $declaration['cleanup'] ?? NULL;
672 $this->managedActions[$key]['update'] = $declaration['update'] ?? 'always';
673 }
674 else {
675 $this->managedActions[$key] = [
676 'module' => $declaration['module'],
677 'name' => $declaration['name'],
678 'entity_type' => $declaration['entity'],
679 'managed_action' => 'create',
680 'params' => $declaration['params'],
681 'cleanup' => $declaration['cleanup'] ?? NULL,
682 'update' => $declaration['update'] ?? 'always',
683 ];
684 }
685 }
686 }
687
688 }