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.
10 class CRM_Core_ManagedEntities
{
13 * Get clean up options.
17 public static function getCleanupOptions() {
19 'always' => ts('Always'),
20 'never' => ts('Never'),
21 'unused' => ts('If Unused'),
27 * Array($status => array($name => CRM_Core_Module)).
29 protected $moduleIndex;
32 * Actions arising from the managed entities.
36 protected $managedActions = [];
40 * List of all entity declarations.
41 * @see CRM_Utils_Hook::managed()
43 protected $declarations;
48 * @return \CRM_Core_ManagedEntities
50 public static function singleton($fresh = FALSE) {
52 if ($fresh ||
!$singleton) {
53 $singleton = new CRM_Core_ManagedEntities(CRM_Core_Module
::getAll());
59 * Perform an asynchronous reconciliation when the transaction ends.
61 public static function scheduleReconciliation() {
62 CRM_Core_Transaction
::addCallback(
63 CRM_Core_Transaction
::PHASE_POST_COMMIT
,
65 CRM_Core_ManagedEntities
::singleton(TRUE)->reconcile();
68 'ManagedEntities::reconcile'
73 * @param array $modules
76 public function __construct(array $modules) {
77 $this->moduleIndex
= $this->createModuleIndex($modules);
81 * Read a managed entity using APIv3.
85 * @param string $moduleName
86 * The name of the module which declared entity.
88 * The symbolic name of the entity.
90 * API representation, or NULL if the entity does not exist
92 public function get($moduleName, $name) {
93 $dao = new CRM_Core_DAO_Managed();
94 $dao->module
= $moduleName;
96 if ($dao->find(TRUE)) {
98 'id' => $dao->entity_id
,
102 $result = civicrm_api3($dao->entity_type
, 'getsingle', $params);
104 catch (Exception
$e) {
105 $this->onApiError($dao->entity_type
, 'getsingle', $params, $result);
115 * Identify any enabled/disabled modules. Add new entities, update
116 * existing entities, and remove orphaned (stale) entities.
118 * @param bool $ignoreUpgradeMode
120 * @throws \CRM_Core_Exception
122 public function reconcile($ignoreUpgradeMode = FALSE) {
123 $this->loadDeclarations();
124 if ($error = $this->validate($this->getDeclarations())) {
125 throw new CRM_Core_Exception($error);
127 $this->loadManagedEntityActions();
128 $this->reconcileEnabledModules();
129 $this->reconcileDisabledModules();
130 $this->reconcileUnknownModules();
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
138 public function revert(array $params) {
139 $mgd = new \
CRM_Core_DAO_Managed();
140 $mgd->copyValues($params);
142 $this->loadDeclarations();
143 $declarations = CRM_Utils_Array
::findAll($this->declarations
, [
144 'module' => $mgd->module
,
145 'name' => $mgd->name
,
146 'entity' => $mgd->entity_type
,
148 if ($mgd->id
&& isset($declarations[0])) {
149 $this->updateExistingEntity($mgd, ['update' => 'always'] +
$declarations[0]);
156 * For all enabled modules, add new entities, update
157 * existing entities, and remove orphaned (stale) entities.
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!
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);
173 * For one enabled module, add new entities, update existing entities,
174 * and remove orphaned (stale) entities.
176 * @param string $module
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);
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);
200 foreach ($this->getManagedEntitiesToCreate(['module' => $module]) as $todo) {
201 $this->insertNewEntity($todo);
206 * Get the managed entities to be created.
208 * @param array $filters
212 protected function getManagedEntitiesToCreate(array $filters = []): array {
213 return $this->getManagedEntities(array_merge($filters, ['managed_action' => 'create']));
217 * Get the managed entities to be updated.
219 * @param array $filters
223 protected function getManagedEntitiesToUpdate(array $filters = []): array {
224 return $this->getManagedEntities(array_merge($filters, ['managed_action' => 'update']));
228 * Get the managed entities to be deleted.
230 * @param array $filters
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'])));
240 * Get the managed entities that fit the criteria.
242 * @param array $filters
246 protected function getManagedEntities(array $filters = []): array {
248 foreach ($this->managedActions
as $actionKey => $action) {
249 foreach ($filters as $filterKey => $filterValue) {
250 if ($action[$filterKey] !== $filterValue) {
254 $return[$actionKey] = $action;
260 * For all disabled modules, disable any managed entities.
262 protected function reconcileDisabledModules() {
263 if (empty($this->moduleIndex
[FALSE])) {
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');
272 while ($dao->fetch()) {
273 $this->disableEntity($dao);
279 * Remove any orphaned (stale) entities that are linked to
282 protected function reconcileUnknownModules() {
284 if (array_key_exists(0, $this->moduleIndex
) && is_array($this->moduleIndex
[0])) {
285 $knownModules = array_merge($knownModules, array_keys($this->moduleIndex
[0]));
287 if (array_key_exists(1, $this->moduleIndex
) && is_array($this->moduleIndex
[1])) {
288 $knownModules = array_merge($knownModules, array_keys($this->moduleIndex
[1]));
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');
298 while ($dao->fetch()) {
299 $this->removeStaleEntity($dao);
304 * Create a new entity.
307 * Entity specification (per hook_civicrm_managedEntities).
309 protected function insertNewEntity($todo) {
310 $params = $todo['params'];
312 if ($params['version'] == 4) {
313 $params['checkPermissions'] = FALSE;
314 // Use "save" instead of "create" action to accommodate a "match" param
315 $params['records'] = [$params['values']];
316 unset($params['values']);
317 $result = civicrm_api4($todo['entity_type'], 'save', $params);
318 $id = $result->first()['id'];
322 $result = civicrm_api($todo['entity_type'], 'create', $params);
323 if (!empty($result['is_error'])) {
324 $this->onApiError($todo['entity_type'], 'create', $params, $result);
329 $dao = new CRM_Core_DAO_Managed();
330 $dao->module
= $todo['module'];
331 $dao->name
= $todo['name'];
332 $dao->entity_type
= $todo['entity_type'];
333 $dao->entity_id
= $id;
334 $dao->cleanup
= $todo['cleanup'] ??
NULL;
339 * Update an entity which is believed to exist.
341 * @param CRM_Core_DAO_Managed $dao
343 * Entity specification (per hook_civicrm_managedEntities).
345 protected function updateExistingEntity($dao, $todo) {
346 $policy = $todo['update'] ??
'always';
347 $doUpdate = ($policy === 'always');
349 if ($policy === 'unmodified') {
350 // If this is not an APIv4 managed entity, the entity_modidfied_date will always be null
351 if (!CRM_Core_BAO_Managed
::isApi4ManagedType($dao->entity_type
)) {
352 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".');
354 $doUpdate = empty($dao->entity_modified_date
);
357 if ($doUpdate && $todo['params']['version'] == 3) {
358 $defaults = ['id' => $dao->entity_id
];
359 if ($this->isActivationSupported($dao->entity_type
)) {
360 $defaults['is_active'] = 1;
362 $params = array_merge($defaults, $todo['params']);
364 $manager = CRM_Extension_System
::singleton()->getManager();
365 if ($dao->entity_type
=== 'Job' && !$manager->extensionIsBeingInstalledOrEnabled($dao->module
)) {
366 // Special treatment for scheduled jobs:
368 // If we're being called as part of enabling/installing a module then
369 // we want the default behaviour of setting is_active = 1.
371 // However, if we're just being called by a normal cache flush then we
372 // should not re-enable a job that an administrator has decided to disable.
374 // Without this logic there was a problem: site admin might disable
375 // a job, but then when there was a flush op, the job was re-enabled
376 // which can cause significant embarrassment, depending on the job
377 // ("Don't worry, sending mailings is disabled right now...").
378 unset($params['is_active']);
381 $result = civicrm_api($dao->entity_type
, 'create', $params);
382 if ($result['is_error']) {
383 $this->onApiError($dao->entity_type
, 'create', $params, $result);
386 elseif ($doUpdate && $todo['params']['version'] == 4) {
387 $params = ['checkPermissions' => FALSE] +
$todo['params'];
388 $params['values']['id'] = $dao->entity_id
;
389 // 'match' param doesn't apply to "update" action
390 unset($params['match']);
391 civicrm_api4($dao->entity_type
, 'update', $params);
394 if (isset($todo['cleanup']) ||
$doUpdate) {
395 $dao->cleanup
= $todo['cleanup'] ??
NULL;
396 // Reset the `entity_modified_date` timestamp if reverting record.
397 $dao->entity_modified_date
= $doUpdate ?
'null' : NULL;
403 * Update an entity which (a) is believed to exist and which (b) ought to be
406 * @param CRM_Core_DAO_Managed $dao
408 * @throws \CiviCRM_API3_Exception
410 protected function disableEntity($dao): void
{
411 $entity_type = $dao->entity_type
;
412 if ($this->isActivationSupported($entity_type)) {
413 // FIXME cascading for payproc types?
416 'id' => $dao->entity_id
,
419 $result = civicrm_api($dao->entity_type
, 'create', $params);
420 if ($result['is_error']) {
421 $this->onApiError($dao->entity_type
, 'create', $params, $result);
423 // Reset the `entity_modified_date` timestamp to indicate that the entity has not been modified by the user.
424 $dao->entity_modified_date
= 'null';
430 * Remove a stale entity (if policy allows).
432 * @param CRM_Core_DAO_Managed $dao
433 * @throws CRM_Core_Exception
435 protected function removeStaleEntity($dao) {
436 $policy = empty($dao->cleanup
) ?
'always' : $dao->cleanup
;
447 if (CRM_Core_BAO_Managed
::isApi4ManagedType($dao->entity_type
)) {
448 $getRefCount = \Civi\Api4\Utils\CoreUtil
::getRefCount($dao->entity_type
, $dao->entity_id
);
451 $getRefCount = civicrm_api3($dao->entity_type
, 'getrefcount', [
452 'id' => $dao->entity_id
,
456 // FIXME: This extra counting should be unnecessary, because getRefCount only returns values if count > 0
458 foreach ($getRefCount as $refCount) {
459 $total +
= $refCount['count'];
462 $doDelete = ($total == 0);
466 throw new CRM_Core_Exception('Unrecognized cleanup policy: ' . $policy);
469 // APIv4 delete - deletion from `civicrm_managed` will be taken care of by
470 // CRM_Core_BAO_Managed::on_hook_civicrm_post()
471 if ($doDelete && CRM_Core_BAO_Managed
::isApi4ManagedType($dao->entity_type
)) {
472 civicrm_api4($dao->entity_type
, 'delete', [
473 'checkPermissions' => FALSE,
474 'where' => [['id', '=', $dao->entity_id
]],
481 'id' => $dao->entity_id
,
483 $check = civicrm_api3($dao->entity_type
, 'get', $params);
484 if ($check['count']) {
485 $result = civicrm_api($dao->entity_type
, 'delete', $params);
486 if ($result['is_error']) {
487 if (isset($dao->name
)) {
488 $params['name'] = $dao->name
;
490 $this->onApiError($dao->entity_type
, 'delete', $params, $result);
493 CRM_Core_DAO
::executeQuery('DELETE FROM civicrm_managed WHERE id = %1', [
494 1 => [$dao->id
, 'Integer'],
504 protected function getDeclarations() {
505 return $this->declarations
;
509 * @param array $modules
510 * Array<CRM_Core_Module>.
513 * indexed by is_active,name
515 protected function createModuleIndex($modules) {
517 foreach ($modules as $module) {
518 $result[$module->is_active
][$module->name
] = $module;
524 * @param array $moduleIndex
525 * @param array $declarations
528 * indexed by module,name
530 protected function createDeclarationIndex($moduleIndex, $declarations) {
532 if (!isset($moduleIndex[TRUE])) {
535 foreach ($moduleIndex[TRUE] as $moduleName => $module) {
536 if ($module->is_active
) {
537 // need an empty array() for all active modules, even if there are no current $declarations
538 $result[$moduleName] = [];
541 foreach ($declarations as $declaration) {
542 $result[$declaration['module']][$declaration['name']] = $declaration;
548 * @param array $declarations
550 * @return string|bool
551 * string on error, or FALSE
553 protected function validate($declarations) {
554 foreach ($declarations as $module => $declare) {
555 foreach (['name', 'module', 'entity', 'params'] as $key) {
556 if (empty($declare[$key])) {
557 $str = print_r($declare, TRUE);
558 return ts('Managed Entity (%1) is missing field "%2": %3', [$module, $key, $str]);
561 if (!$this->isModuleRecognised($declare['module'])) {
562 return ts('Entity declaration references invalid or inactive module name [%1]', [$declare['module']]);
569 * Is the module recognised (as an enabled or disabled extension in the system).
571 * @param string $module
575 protected function isModuleRecognised(string $module): bool {
576 return $this->isModuleDisabled($module) ||
$this->isModuleEnabled($module);
580 * Is the module enabled.
582 * @param string $module
586 protected function isModuleEnabled(string $module): bool {
587 return isset($this->moduleIndex
[TRUE][$module]);
591 * Is the module disabled.
593 * @param string $module
597 protected function isModuleDisabled(string $module): bool {
598 return isset($this->moduleIndex
[FALSE][$module]);
602 * @param array $declarations
606 protected function cleanDeclarations(array $declarations): array {
607 foreach ($declarations as $name => &$declare) {
608 if (!array_key_exists('name', $declare)) {
609 $declare['name'] = $name;
612 return $declarations;
616 * @param string $entity
617 * @param string $action
618 * @param array $params
619 * @param array $result
623 protected function onApiError($entity, $action, $params, $result) {
624 CRM_Core_Error
::debug_var('ManagedEntities_failed', [
630 throw new Exception('API error: ' . $result['error_message'] . ' on ' . $entity . '.' . $action
631 . (!empty($params['name']) ?
'( entity name ' . $params['name'] . ')' : '')
636 * Determine if an entity supports APIv3-based activation/de-activation.
637 * @param string $entity_type
640 * @throws \CiviCRM_API3_Exception
642 private function isActivationSupported(string $entity_type): bool {
643 if (!isset(Civi
::$statics[__CLASS__
][__FUNCTION__
][$entity_type])) {
644 $actions = civicrm_api3($entity_type, 'getactions', [])['values'];
645 Civi
::$statics[__CLASS__
][__FUNCTION__
][$entity_type] = FALSE;
646 if (in_array('create', $actions, TRUE) && in_array('getfields', $actions)) {
647 $fields = civicrm_api3($entity_type, 'getfields', ['action' => 'create'])['values'];
648 Civi
::$statics[__CLASS__
][__FUNCTION__
][$entity_type] = array_key_exists('is_active', $fields);
651 return Civi
::$statics[__CLASS__
][__FUNCTION__
][$entity_type];
655 * Load declarations into the class property.
657 * This picks it up from hooks and enabled components.
659 protected function loadDeclarations(): void
{
660 $this->declarations
= [];
661 foreach (CRM_Core_Component
::getEnabledComponents() as $component) {
662 $this->declarations
= array_merge($this->declarations
, $component->getManagedEntities());
664 CRM_Utils_Hook
::managed($this->declarations
);
665 $this->declarations
= $this->cleanDeclarations($this->declarations
);
668 protected function loadManagedEntityActions(): void
{
669 $managedEntities = Managed
::get(FALSE)->addSelect('*')->execute();
670 $this->managedActions
= [];
671 foreach ($managedEntities as $managedEntity) {
672 $key = "{$managedEntity['module']}_{$managedEntity['name']}_{$managedEntity['entity_type']}";
673 // Set to 'delete' - it will be overwritten below if it is to be updated.
675 $this->managedActions
[$key] = array_merge($managedEntity, ['managed_action' => $action]);
677 foreach ($this->declarations
as $declaration) {
678 $key = "{$declaration['module']}_{$declaration['name']}_{$declaration['entity']}";
679 if (isset($this->managedActions
[$key])) {
680 $this->managedActions
[$key]['params'] = $declaration['params'];
681 $this->managedActions
[$key]['managed_action'] = 'update';
682 $this->managedActions
[$key]['cleanup'] = $declaration['cleanup'] ??
NULL;
683 $this->managedActions
[$key]['update'] = $declaration['update'] ??
'always';
686 $this->managedActions
[$key] = [
687 'module' => $declaration['module'],
688 'name' => $declaration['name'],
689 'entity_type' => $declaration['entity'],
690 'managed_action' => 'create',
691 'params' => $declaration['params'],
692 'cleanup' => $declaration['cleanup'] ??
NULL,
693 'update' => $declaration['update'] ??
'always',