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