Remove always true if conditions (whitespace in next commit)
[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
6a488035 31 /**
d09edf64 32 * Get an instance.
ba3228d1
EM
33 * @param bool $fresh
34 * @return \CRM_Core_ManagedEntities
6a488035
TO
35 */
36 public static function singleton($fresh = FALSE) {
37 static $singleton;
38 if ($fresh || !$singleton) {
8cb5ca10 39 $singleton = new CRM_Core_ManagedEntities(CRM_Core_Module::getAll());
6a488035
TO
40 }
41 return $singleton;
42 }
43
9c19292b
TO
44 /**
45 * Perform an asynchronous reconciliation when the transaction ends.
46 */
ba3228d1 47 public static function scheduleReconciliation() {
9c19292b
TO
48 CRM_Core_Transaction::addCallback(
49 CRM_Core_Transaction::PHASE_POST_COMMIT,
50 function () {
e70a7fc0 51 CRM_Core_ManagedEntities::singleton(TRUE)->reconcile();
9c19292b 52 },
be2fb01f 53 [],
9c19292b
TO
54 'ManagedEntities::reconcile'
55 );
56 }
57
6a488035 58 /**
6a0b768e
TO
59 * @param array $modules
60 * CRM_Core_Module.
6a488035 61 */
8cb5ca10 62 public function __construct(array $modules) {
85917c3b 63 $this->moduleIndex = $this->createModuleIndex($modules);
6a488035
TO
64 }
65
66 /**
88718db2 67 * Read a managed entity using APIv3.
378e2654 68 *
a092536e
EM
69 * @deprecated
70 *
88718db2
TO
71 * @param string $moduleName
72 * The name of the module which declared entity.
73 * @param string $name
74 * The symbolic name of the entity.
72b3a70c
CW
75 * @return array|NULL
76 * API representation, or NULL if the entity does not exist
6a488035
TO
77 */
78 public function get($moduleName, $name) {
79 $dao = new CRM_Core_DAO_Managed();
80 $dao->module = $moduleName;
81 $dao->name = $name;
82 if ($dao->find(TRUE)) {
be2fb01f 83 $params = [
6a488035 84 'id' => $dao->entity_id,
be2fb01f 85 ];
bbf66e9c 86 $result = NULL;
637ea2cf
E
87 try {
88 $result = civicrm_api3($dao->entity_type, 'getsingle', $params);
89 }
90 catch (Exception $e) {
e9b95545 91 $this->onApiError($dao->entity_type, 'getsingle', $params, $result);
6a488035 92 }
637ea2cf 93 return $result;
0db6c3e1
TO
94 }
95 else {
6a488035
TO
96 return NULL;
97 }
98 }
99
88718db2
TO
100 /**
101 * Identify any enabled/disabled modules. Add new entities, update
102 * existing entities, and remove orphaned (stale) entities.
8cb5ca10 103 *
a7db5554
CW
104 * @param array $modules
105 * Limits scope of reconciliation to specific module(s).
8cb5ca10 106 * @throws \CRM_Core_Exception
88718db2 107 */
a7db5554
CW
108 public function reconcile($modules = NULL) {
109 $modules = $modules ? (array) $modules : NULL;
12485e4f
CW
110 $declarations = $this->getDeclarations($modules);
111 $plan = $this->createPlan($declarations, $modules);
13169d44 112 $this->reconcileEntities($plan);
6a488035
TO
113 }
114
9626d0a1
CW
115 /**
116 * Force-revert a record back to its original state.
117 * @param array $params
118 * Key->value properties of CRM_Core_DAO_Managed used to match an existing record
119 */
120 public function revert(array $params) {
121 $mgd = new \CRM_Core_DAO_Managed();
122 $mgd->copyValues($params);
123 $mgd->find(TRUE);
12485e4f
CW
124 $declarations = $this->getDeclarations([$mgd->module]);
125 $declarations = CRM_Utils_Array::findAll($declarations, [
9626d0a1
CW
126 'module' => $mgd->module,
127 'name' => $mgd->name,
128 'entity' => $mgd->entity_type,
129 ]);
130 if ($mgd->id && isset($declarations[0])) {
a7db5554 131 $this->updateExistingEntity(['update' => 'always'] + $declarations[0] + $mgd->toArray());
9626d0a1
CW
132 return TRUE;
133 }
134 return FALSE;
135 }
136
88718db2 137 /**
a7db5554 138 * Take appropriate action on every managed entity.
13169d44
CW
139 *
140 * @param array[] $plan
88718db2 141 */
13169d44
CW
142 private function reconcileEntities(array $plan): void {
143 foreach ($this->filterPlanByAction($plan, 'update') as $item) {
a7db5554 144 $this->updateExistingEntity($item);
6a488035 145 }
a7db5554 146 // reverse-order so that child entities are cleaned up before their parents
13169d44 147 foreach (array_reverse($this->filterPlanByAction($plan, 'delete')) as $item) {
a7db5554 148 $this->removeStaleEntity($item);
6a488035 149 }
13169d44 150 foreach ($this->filterPlanByAction($plan, 'create') as $item) {
a7db5554 151 $this->insertNewEntity($item);
a092536e 152 }
13169d44 153 foreach ($this->filterPlanByAction($plan, 'disable') as $item) {
a7db5554 154 $this->disableEntity($item);
6a488035
TO
155 }
156 }
157
a092536e
EM
158 /**
159 * Get the managed entities that fit the criteria.
160 *
13169d44 161 * @param array[] $plan
a7db5554 162 * @param string $action
a092536e
EM
163 *
164 * @return array
165 */
13169d44 166 private function filterPlanByAction(array $plan, string $action): array {
12485e4f 167 return CRM_Utils_Array::findAll($plan, ['managed_action' => $action]);
bbf66e9c 168 }
6a488035 169
7cddb4ae 170 /**
88718db2 171 * Create a new entity.
7cddb4ae 172 *
a7db5554 173 * @param array $item
6a0b768e 174 * Entity specification (per hook_civicrm_managedEntities).
7cddb4ae 175 */
a7db5554
CW
176 protected function insertNewEntity(array $item) {
177 $params = $item['params'];
f43ca71f
CW
178 // APIv4
179 if ($params['version'] == 4) {
180 $params['checkPermissions'] = FALSE;
181 // Use "save" instead of "create" action to accommodate a "match" param
182 $params['records'] = [$params['values']];
183 unset($params['values']);
a7db5554 184 $result = civicrm_api4($item['entity_type'], 'save', $params);
f43ca71f 185 $id = $result->first()['id'];
9626d0a1 186 }
f43ca71f
CW
187 // APIv3
188 else {
a7db5554 189 $result = civicrm_api($item['entity_type'], 'create', $params);
f43ca71f 190 if (!empty($result['is_error'])) {
a7db5554 191 $this->onApiError($item['entity_type'], 'create', $params, $result);
f43ca71f
CW
192 }
193 $id = $result['id'];
7cddb4ae
TO
194 }
195
196 $dao = new CRM_Core_DAO_Managed();
a7db5554
CW
197 $dao->module = $item['module'];
198 $dao->name = $item['name'];
199 $dao->entity_type = $item['entity_type'];
f43ca71f 200 $dao->entity_id = $id;
a7db5554 201 $dao->cleanup = $item['cleanup'] ?? NULL;
7cddb4ae
TO
202 $dao->save();
203 }
204
205 /**
20429eb9 206 * Update an entity which is believed to exist.
7cddb4ae 207 *
a7db5554 208 * @param array $item
6a0b768e 209 * Entity specification (per hook_civicrm_managedEntities).
7cddb4ae 210 */
a7db5554
CW
211 private function updateExistingEntity(array $item) {
212 $policy = $item['update'] ?? 'always';
35c1c211 213 $doUpdate = ($policy === 'always');
0dd54586 214
095e8ae4
CW
215 if ($policy === 'unmodified') {
216 // If this is not an APIv4 managed entity, the entity_modidfied_date will always be null
a7db5554
CW
217 if (!CRM_Core_BAO_Managed::isApi4ManagedType($item['entity_type'])) {
218 Civi::log()->warning('ManagedEntity update policy "unmodified" specified for entity type ' . $item['entity_type'] . ' which is not an APIv4 ManagedEntity. Falling back to policy "always".');
095e8ae4 219 }
a7db5554 220 $doUpdate = empty($item['entity_modified_date']);
095e8ae4
CW
221 }
222
a7db5554
CW
223 if ($doUpdate && $item['params']['version'] == 3) {
224 $defaults = ['id' => $item['entity_id']];
225 if ($this->isActivationSupported($item['entity_type'])) {
3cf02556
TO
226 $defaults['is_active'] = 1;
227 }
a7db5554 228 $params = array_merge($defaults, $item['params']);
20429eb9
RL
229
230 $manager = CRM_Extension_System::singleton()->getManager();
a7db5554 231 if ($item['entity_type'] === 'Job' && !$manager->extensionIsBeingInstalledOrEnabled($item['module'])) {
20429eb9
RL
232 // Special treatment for scheduled jobs:
233 //
234 // If we're being called as part of enabling/installing a module then
235 // we want the default behaviour of setting is_active = 1.
236 //
237 // However, if we're just being called by a normal cache flush then we
238 // should not re-enable a job that an administrator has decided to disable.
239 //
240 // Without this logic there was a problem: site admin might disable
241 // a job, but then when there was a flush op, the job was re-enabled
242 // which can cause significant embarrassment, depending on the job
243 // ("Don't worry, sending mailings is disabled right now...").
244 unset($params['is_active']);
245 }
246
a7db5554 247 $result = civicrm_api($item['entity_type'], 'create', $params);
0dd54586 248 if ($result['is_error']) {
a7db5554 249 $this->onApiError($item['entity_type'], 'create', $params, $result);
0dd54586 250 }
7cddb4ae 251 }
a7db5554
CW
252 elseif ($doUpdate && $item['params']['version'] == 4) {
253 $params = ['checkPermissions' => FALSE] + $item['params'];
254 $params['values']['id'] = $item['entity_id'];
f43ca71f
CW
255 // 'match' param doesn't apply to "update" action
256 unset($params['match']);
a7db5554 257 civicrm_api4($item['entity_type'], 'update', $params);
9626d0a1 258 }
1f103dc4 259
a7db5554
CW
260 if (isset($item['cleanup']) || $doUpdate) {
261 $dao = new CRM_Core_DAO_Managed();
262 $dao->id = $item['id'];
263 $dao->cleanup = $item['cleanup'] ?? NULL;
69e13f9b
CW
264 // Reset the `entity_modified_date` timestamp if reverting record.
265 $dao->entity_modified_date = $doUpdate ? 'null' : NULL;
1f103dc4
TO
266 $dao->update();
267 }
7cddb4ae
TO
268 }
269
270 /**
271 * Update an entity which (a) is believed to exist and which (b) ought to be
272 * inactive.
273 *
a7db5554 274 * @param array $item
8b91d849 275 *
276 * @throws \CiviCRM_API3_Exception
7cddb4ae 277 */
a7db5554
CW
278 protected function disableEntity(array $item): void {
279 $entity_type = $item['entity_type'];
fc625166 280 if ($this->isActivationSupported($entity_type)) {
7cddb4ae 281 // FIXME cascading for payproc types?
be2fb01f 282 $params = [
7cddb4ae 283 'version' => 3,
a7db5554 284 'id' => $item['entity_id'],
7cddb4ae 285 'is_active' => 0,
be2fb01f 286 ];
a7db5554 287 $result = civicrm_api($item['entity_type'], 'create', $params);
7cddb4ae 288 if ($result['is_error']) {
a7db5554 289 $this->onApiError($item['entity_type'], 'create', $params, $result);
7cddb4ae 290 }
69e13f9b 291 // Reset the `entity_modified_date` timestamp to indicate that the entity has not been modified by the user.
a7db5554
CW
292 $dao = new CRM_Core_DAO_Managed();
293 $dao->id = $item['id'];
69e13f9b
CW
294 $dao->entity_modified_date = 'null';
295 $dao->update();
7cddb4ae
TO
296 }
297 }
298
bbf66e9c 299 /**
88718db2 300 * Remove a stale entity (if policy allows).
bbf66e9c 301 *
a7db5554 302 * @param array $item
a092536e 303 * @throws CRM_Core_Exception
bbf66e9c 304 */
a7db5554
CW
305 protected function removeStaleEntity(array $item) {
306 $policy = empty($item['cleanup']) ? 'always' : $item['cleanup'];
378e2654
TO
307 switch ($policy) {
308 case 'always':
309 $doDelete = TRUE;
310 break;
ea100cb5 311
378e2654
TO
312 case 'never':
313 $doDelete = FALSE;
314 break;
ea100cb5 315
378e2654 316 case 'unused':
a7db5554
CW
317 if (CRM_Core_BAO_Managed::isApi4ManagedType($item['entity_type'])) {
318 $getRefCount = \Civi\Api4\Utils\CoreUtil::getRefCount($item['entity_type'], $item['entity_id']);
095e8ae4
CW
319 }
320 else {
a7db5554
CW
321 $getRefCount = civicrm_api3($item['entity_type'], 'getrefcount', [
322 'id' => $item['entity_id'],
095e8ae4
CW
323 ])['values'];
324 }
378e2654 325
095e8ae4 326 // FIXME: This extra counting should be unnecessary, because getRefCount only returns values if count > 0
378e2654 327 $total = 0;
095e8ae4 328 foreach ($getRefCount as $refCount) {
378e2654
TO
329 $total += $refCount['count'];
330 }
331
332 $doDelete = ($total == 0);
333 break;
ea100cb5 334
378e2654 335 default:
a092536e 336 throw new CRM_Core_Exception('Unrecognized cleanup policy: ' . $policy);
378e2654 337 }
bbf66e9c 338
a2bf5923
CW
339 // APIv4 delete - deletion from `civicrm_managed` will be taken care of by
340 // CRM_Core_BAO_Managed::on_hook_civicrm_post()
a7db5554
CW
341 if ($doDelete && CRM_Core_BAO_Managed::isApi4ManagedType($item['entity_type'])) {
342 civicrm_api4($item['entity_type'], 'delete', [
081629dc 343 'checkPermissions' => FALSE,
a7db5554 344 'where' => [['id', '=', $item['entity_id']]],
a2bf5923
CW
345 ]);
346 }
347 // APIv3 delete
348 elseif ($doDelete) {
be2fb01f 349 $params = [
1f103dc4 350 'version' => 3,
a7db5554 351 'id' => $item['entity_id'],
be2fb01f 352 ];
a7db5554 353 $check = civicrm_api3($item['entity_type'], 'get', $params);
a2bf5923 354 if ($check['count']) {
a7db5554 355 $result = civicrm_api($item['entity_type'], 'delete', $params);
a60c0bc8 356 if ($result['is_error']) {
a7db5554
CW
357 if (isset($item['name'])) {
358 $params['name'] = $item['name'];
9f4f065a 359 }
a7db5554 360 $this->onApiError($item['entity_type'], 'delete', $params, $result);
a60c0bc8 361 }
a60c0bc8 362 }
be2fb01f 363 CRM_Core_DAO::executeQuery('DELETE FROM civicrm_managed WHERE id = %1', [
a7db5554 364 1 => [$item['id'], 'Integer'],
be2fb01f 365 ]);
1f103dc4 366 }
6a488035
TO
367 }
368
369 /**
88718db2
TO
370 * @param array $modules
371 * Array<CRM_Core_Module>.
77b97be7 372 *
a6c01b45
CW
373 * @return array
374 * indexed by is_active,name
6a488035 375 */
85917c3b 376 protected function createModuleIndex($modules) {
be2fb01f 377 $result = [];
6a488035
TO
378 foreach ($modules as $module) {
379 $result[$module->is_active][$module->name] = $module;
380 }
381 return $result;
382 }
383
384 /**
88718db2
TO
385 * @param array $moduleIndex
386 * @param array $declarations
77b97be7 387 *
a6c01b45
CW
388 * @return array
389 * indexed by module,name
6a488035 390 */
85917c3b 391 protected function createDeclarationIndex($moduleIndex, $declarations) {
be2fb01f 392 $result = [];
6a488035
TO
393 if (!isset($moduleIndex[TRUE])) {
394 return $result;
395 }
396 foreach ($moduleIndex[TRUE] as $moduleName => $module) {
397 if ($module->is_active) {
398 // need an empty array() for all active modules, even if there are no current $declarations
be2fb01f 399 $result[$moduleName] = [];
6a488035
TO
400 }
401 }
402 foreach ($declarations as $declaration) {
403 $result[$declaration['module']][$declaration['name']] = $declaration;
404 }
405 return $result;
406 }
407
408 /**
fa3fdebc 409 * @param array $declarations
fd31fa4c 410 *
a7db5554 411 * @throws CRM_Core_Exception
6a488035 412 */
85917c3b 413 protected function validate($declarations) {
9cf68fcc 414 foreach ($declarations as $module => $declare) {
be2fb01f 415 foreach (['name', 'module', 'entity', 'params'] as $key) {
6a488035
TO
416 if (empty($declare[$key])) {
417 $str = print_r($declare, TRUE);
a7db5554 418 throw new CRM_Core_Exception(ts('Managed Entity (%1) is missing field "%2": %3', [$module, $key, $str]));
6a488035
TO
419 }
420 }
9cf68fcc 421 if (!$this->isModuleRecognised($declare['module'])) {
a7db5554 422 throw new CRM_Core_Exception(ts('Entity declaration references invalid or inactive module name [%1]', [$declare['module']]));
9cf68fcc 423 }
6a488035 424 }
6a488035
TO
425 }
426
9cf68fcc
EM
427 /**
428 * Is the module recognised (as an enabled or disabled extension in the system).
429 *
430 * @param string $module
431 *
432 * @return bool
433 */
434 protected function isModuleRecognised(string $module): bool {
435 return $this->isModuleDisabled($module) || $this->isModuleEnabled($module);
436 }
437
438 /**
439 * Is the module enabled.
440 *
441 * @param string $module
442 *
443 * @return bool
444 */
445 protected function isModuleEnabled(string $module): bool {
446 return isset($this->moduleIndex[TRUE][$module]);
447 }
448
449 /**
450 * Is the module disabled.
451 *
452 * @param string $module
453 *
454 * @return bool
455 */
456 protected function isModuleDisabled(string $module): bool {
457 return isset($this->moduleIndex[FALSE][$module]);
458 }
459
a0ee3941 460 /**
e9b95545
TO
461 * @param string $entity
462 * @param string $action
463 * @param array $params
464 * @param array $result
a0ee3941
EM
465 *
466 * @throws Exception
467 */
e9b95545 468 protected function onApiError($entity, $action, $params, $result) {
be2fb01f 469 CRM_Core_Error::debug_var('ManagedEntities_failed', [
e9b95545
TO
470 'entity' => $entity,
471 'action' => $action,
6a488035
TO
472 'params' => $params,
473 'result' => $result,
be2fb01f 474 ]);
35c1c211 475 throw new Exception('API error: ' . $result['error_message'] . ' on ' . $entity . '.' . $action
9f4f065a 476 . (!empty($params['name']) ? '( entity name ' . $params['name'] . ')' : '')
35c1c211 477 );
cbb7c7e0 478 }
96025800 479
fc625166
TO
480 /**
481 * Determine if an entity supports APIv3-based activation/de-activation.
482 * @param string $entity_type
483 *
484 * @return bool
485 * @throws \CiviCRM_API3_Exception
486 */
487 private function isActivationSupported(string $entity_type): bool {
488 if (!isset(Civi::$statics[__CLASS__][__FUNCTION__][$entity_type])) {
489 $actions = civicrm_api3($entity_type, 'getactions', [])['values'];
490 Civi::$statics[__CLASS__][__FUNCTION__][$entity_type] = FALSE;
491 if (in_array('create', $actions, TRUE) && in_array('getfields', $actions)) {
492 $fields = civicrm_api3($entity_type, 'getfields', ['action' => 'create'])['values'];
493 Civi::$statics[__CLASS__][__FUNCTION__][$entity_type] = array_key_exists('is_active', $fields);
494 }
495 }
496 return Civi::$statics[__CLASS__][__FUNCTION__][$entity_type];
497 }
498
78ce6ebb 499 /**
12485e4f 500 * Load managed entity declarations.
78ce6ebb
EM
501 *
502 * This picks it up from hooks and enabled components.
a7db5554
CW
503 *
504 * @param array|null $modules
505 * Limit reconciliation specified modules.
12485e4f 506 * @return array[]
78ce6ebb 507 */
12485e4f 508 protected function getDeclarations($modules = NULL): array {
a7db5554
CW
509 $declarations = [];
510 // Exclude components if given a module name.
511 if (!$modules || $modules === ['civicrm']) {
512 foreach (CRM_Core_Component::getEnabledComponents() as $component) {
513 $declarations = array_merge($declarations, $component->getManagedEntities());
514 }
78ce6ebb 515 }
fdc67a75 516 CRM_Utils_Hook::managed($declarations, $modules);
a7db5554 517 $this->validate($declarations);
12485e4f
CW
518 foreach (array_keys($declarations) as $name) {
519 $declarations[$name] += ['name' => $name];
520 }
521 return $declarations;
78ce6ebb
EM
522 }
523
a7db5554
CW
524 /**
525 * Builds $this->managedActions array
526 *
12485e4f 527 * @param array $declarations
a7db5554 528 * @param array|null $modules
13169d44 529 * @return array[]
a7db5554 530 */
12485e4f 531 protected function createPlan(array $declarations, $modules = NULL): array {
a7db5554
CW
532 $where = $modules ? [['module', 'IN', $modules]] : [];
533 $managedEntities = Managed::get(FALSE)
534 ->setWhere($where)
535 ->execute();
13169d44 536 $plan = [];
a092536e
EM
537 foreach ($managedEntities as $managedEntity) {
538 $key = "{$managedEntity['module']}_{$managedEntity['name']}_{$managedEntity['entity_type']}";
a7db5554
CW
539 // Set to disable or delete if module is disabled or missing - it will be overwritten below module is active.
540 $action = $this->isModuleDisabled($managedEntity['module']) ? 'disable' : 'delete';
13169d44 541 $plan[$key] = array_merge($managedEntity, ['managed_action' => $action]);
a092536e 542 }
12485e4f 543 foreach ($declarations as $declaration) {
a092536e 544 $key = "{$declaration['module']}_{$declaration['name']}_{$declaration['entity']}";
13169d44
CW
545 if (isset($plan[$key])) {
546 $plan[$key]['params'] = $declaration['params'];
547 $plan[$key]['managed_action'] = 'update';
548 $plan[$key]['cleanup'] = $declaration['cleanup'] ?? NULL;
549 $plan[$key]['update'] = $declaration['update'] ?? 'always';
a092536e
EM
550 }
551 else {
13169d44 552 $plan[$key] = [
a092536e
EM
553 'module' => $declaration['module'],
554 'name' => $declaration['name'],
555 'entity_type' => $declaration['entity'],
556 'managed_action' => 'create',
557 'params' => $declaration['params'],
558 'cleanup' => $declaration['cleanup'] ?? NULL,
559 'update' => $declaration['update'] ?? 'always',
560 ];
561 }
562 }
13169d44 563 return $plan;
a092536e
EM
564 }
565
6a488035 566}