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