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 * Plan for what to do with each managed entity.
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 array $modules
119 * Limits scope of reconciliation to specific module(s).
120 * @throws \CRM_Core_Exception
122 public function reconcile($modules = NULL) {
123 $modules = $modules ?
(array) $modules : NULL;
124 $this->loadDeclarations($modules);
125 $this->createPlan($modules);
126 $this->reconcileEntities();
130 * Force-revert a record back to its original state.
131 * @param array $params
132 * Key->value properties of CRM_Core_DAO_Managed used to match an existing record
134 public function revert(array $params) {
135 $mgd = new \
CRM_Core_DAO_Managed();
136 $mgd->copyValues($params);
138 $this->loadDeclarations([$mgd->module
]);
139 $declarations = CRM_Utils_Array
::findAll($this->declarations
, [
140 'module' => $mgd->module
,
141 'name' => $mgd->name
,
142 'entity' => $mgd->entity_type
,
144 if ($mgd->id
&& isset($declarations[0])) {
145 $this->updateExistingEntity(['update' => 'always'] +
$declarations[0] +
$mgd->toArray());
152 * Take appropriate action on every managed entity.
154 private function reconcileEntities(): void
{
155 foreach ($this->getManagedAction('update') as $item) {
156 $this->updateExistingEntity($item);
158 // reverse-order so that child entities are cleaned up before their parents
159 foreach (array_reverse($this->getManagedAction('delete')) as $item) {
160 $this->removeStaleEntity($item);
162 foreach ($this->getManagedAction('create') as $item) {
163 $this->insertNewEntity($item);
165 foreach ($this->getManagedAction('disable') as $item) {
166 $this->disableEntity($item);
171 * Get the managed entities that fit the criteria.
173 * @param string $action
177 private function getManagedAction(string $action): array {
178 return CRM_Utils_Array
::findAll($this->plan
, ['managed_action' => $action]);
182 * Create a new entity.
185 * Entity specification (per hook_civicrm_managedEntities).
187 protected function insertNewEntity(array $item) {
188 $params = $item['params'];
190 if ($params['version'] == 4) {
191 $params['checkPermissions'] = FALSE;
192 // Use "save" instead of "create" action to accommodate a "match" param
193 $params['records'] = [$params['values']];
194 unset($params['values']);
195 $result = civicrm_api4($item['entity_type'], 'save', $params);
196 $id = $result->first()['id'];
200 $result = civicrm_api($item['entity_type'], 'create', $params);
201 if (!empty($result['is_error'])) {
202 $this->onApiError($item['entity_type'], 'create', $params, $result);
207 $dao = new CRM_Core_DAO_Managed();
208 $dao->module
= $item['module'];
209 $dao->name
= $item['name'];
210 $dao->entity_type
= $item['entity_type'];
211 $dao->entity_id
= $id;
212 $dao->cleanup
= $item['cleanup'] ??
NULL;
217 * Update an entity which is believed to exist.
220 * Entity specification (per hook_civicrm_managedEntities).
222 private function updateExistingEntity(array $item) {
223 $policy = $item['update'] ??
'always';
224 $doUpdate = ($policy === 'always');
226 if ($policy === 'unmodified') {
227 // If this is not an APIv4 managed entity, the entity_modidfied_date will always be null
228 if (!CRM_Core_BAO_Managed
::isApi4ManagedType($item['entity_type'])) {
229 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".');
231 $doUpdate = empty($item['entity_modified_date']);
234 if ($doUpdate && $item['params']['version'] == 3) {
235 $defaults = ['id' => $item['entity_id']];
236 if ($this->isActivationSupported($item['entity_type'])) {
237 $defaults['is_active'] = 1;
239 $params = array_merge($defaults, $item['params']);
241 $manager = CRM_Extension_System
::singleton()->getManager();
242 if ($item['entity_type'] === 'Job' && !$manager->extensionIsBeingInstalledOrEnabled($item['module'])) {
243 // Special treatment for scheduled jobs:
245 // If we're being called as part of enabling/installing a module then
246 // we want the default behaviour of setting is_active = 1.
248 // However, if we're just being called by a normal cache flush then we
249 // should not re-enable a job that an administrator has decided to disable.
251 // Without this logic there was a problem: site admin might disable
252 // a job, but then when there was a flush op, the job was re-enabled
253 // which can cause significant embarrassment, depending on the job
254 // ("Don't worry, sending mailings is disabled right now...").
255 unset($params['is_active']);
258 $result = civicrm_api($item['entity_type'], 'create', $params);
259 if ($result['is_error']) {
260 $this->onApiError($item['entity_type'], 'create', $params, $result);
263 elseif ($doUpdate && $item['params']['version'] == 4) {
264 $params = ['checkPermissions' => FALSE] +
$item['params'];
265 $params['values']['id'] = $item['entity_id'];
266 // 'match' param doesn't apply to "update" action
267 unset($params['match']);
268 civicrm_api4($item['entity_type'], 'update', $params);
271 if (isset($item['cleanup']) ||
$doUpdate) {
272 $dao = new CRM_Core_DAO_Managed();
273 $dao->id
= $item['id'];
274 $dao->cleanup
= $item['cleanup'] ??
NULL;
275 // Reset the `entity_modified_date` timestamp if reverting record.
276 $dao->entity_modified_date
= $doUpdate ?
'null' : NULL;
282 * Update an entity which (a) is believed to exist and which (b) ought to be
287 * @throws \CiviCRM_API3_Exception
289 protected function disableEntity(array $item): void
{
290 $entity_type = $item['entity_type'];
291 if ($this->isActivationSupported($entity_type)) {
292 // FIXME cascading for payproc types?
295 'id' => $item['entity_id'],
298 $result = civicrm_api($item['entity_type'], 'create', $params);
299 if ($result['is_error']) {
300 $this->onApiError($item['entity_type'], 'create', $params, $result);
302 // Reset the `entity_modified_date` timestamp to indicate that the entity has not been modified by the user.
303 $dao = new CRM_Core_DAO_Managed();
304 $dao->id
= $item['id'];
305 $dao->entity_modified_date
= 'null';
311 * Remove a stale entity (if policy allows).
314 * @throws CRM_Core_Exception
316 protected function removeStaleEntity(array $item) {
317 $policy = empty($item['cleanup']) ?
'always' : $item['cleanup'];
328 if (CRM_Core_BAO_Managed
::isApi4ManagedType($item['entity_type'])) {
329 $getRefCount = \Civi\Api4\Utils\CoreUtil
::getRefCount($item['entity_type'], $item['entity_id']);
332 $getRefCount = civicrm_api3($item['entity_type'], 'getrefcount', [
333 'id' => $item['entity_id'],
337 // FIXME: This extra counting should be unnecessary, because getRefCount only returns values if count > 0
339 foreach ($getRefCount as $refCount) {
340 $total +
= $refCount['count'];
343 $doDelete = ($total == 0);
347 throw new CRM_Core_Exception('Unrecognized cleanup policy: ' . $policy);
350 // APIv4 delete - deletion from `civicrm_managed` will be taken care of by
351 // CRM_Core_BAO_Managed::on_hook_civicrm_post()
352 if ($doDelete && CRM_Core_BAO_Managed
::isApi4ManagedType($item['entity_type'])) {
353 civicrm_api4($item['entity_type'], 'delete', [
354 'checkPermissions' => FALSE,
355 'where' => [['id', '=', $item['entity_id']]],
362 'id' => $item['entity_id'],
364 $check = civicrm_api3($item['entity_type'], 'get', $params);
365 if ($check['count']) {
366 $result = civicrm_api($item['entity_type'], 'delete', $params);
367 if ($result['is_error']) {
368 if (isset($item['name'])) {
369 $params['name'] = $item['name'];
371 $this->onApiError($item['entity_type'], 'delete', $params, $result);
374 CRM_Core_DAO
::executeQuery('DELETE FROM civicrm_managed WHERE id = %1', [
375 1 => [$item['id'], 'Integer'],
385 protected function getDeclarations() {
386 return $this->declarations
;
390 * @param array $modules
391 * Array<CRM_Core_Module>.
394 * indexed by is_active,name
396 protected function createModuleIndex($modules) {
398 foreach ($modules as $module) {
399 $result[$module->is_active
][$module->name
] = $module;
405 * @param array $moduleIndex
406 * @param array $declarations
409 * indexed by module,name
411 protected function createDeclarationIndex($moduleIndex, $declarations) {
413 if (!isset($moduleIndex[TRUE])) {
416 foreach ($moduleIndex[TRUE] as $moduleName => $module) {
417 if ($module->is_active
) {
418 // need an empty array() for all active modules, even if there are no current $declarations
419 $result[$moduleName] = [];
422 foreach ($declarations as $declaration) {
423 $result[$declaration['module']][$declaration['name']] = $declaration;
429 * @param array $declarations
431 * @throws CRM_Core_Exception
433 protected function validate($declarations) {
434 foreach ($declarations as $module => $declare) {
435 foreach (['name', 'module', 'entity', 'params'] as $key) {
436 if (empty($declare[$key])) {
437 $str = print_r($declare, TRUE);
438 throw new CRM_Core_Exception(ts('Managed Entity (%1) is missing field "%2": %3', [$module, $key, $str]));
441 if (!$this->isModuleRecognised($declare['module'])) {
442 throw new CRM_Core_Exception(ts('Entity declaration references invalid or inactive module name [%1]', [$declare['module']]));
448 * Is the module recognised (as an enabled or disabled extension in the system).
450 * @param string $module
454 protected function isModuleRecognised(string $module): bool {
455 return $this->isModuleDisabled($module) ||
$this->isModuleEnabled($module);
459 * Is the module enabled.
461 * @param string $module
465 protected function isModuleEnabled(string $module): bool {
466 return isset($this->moduleIndex
[TRUE][$module]);
470 * Is the module disabled.
472 * @param string $module
476 protected function isModuleDisabled(string $module): bool {
477 return isset($this->moduleIndex
[FALSE][$module]);
481 * @param array $declarations
482 * @param string $moduleName
483 * Filter to a single module.
485 protected function setDeclarations(array $declarations, $moduleName = NULL) {
486 $this->declarations
= [];
487 foreach ($declarations as $name => $declare) {
488 $declare +
= ['name' => $name];
489 if (!$moduleName ||
$declare['module'] === $moduleName) {
490 $this->declarations
[$name] = $declare;
496 * @param string $entity
497 * @param string $action
498 * @param array $params
499 * @param array $result
503 protected function onApiError($entity, $action, $params, $result) {
504 CRM_Core_Error
::debug_var('ManagedEntities_failed', [
510 throw new Exception('API error: ' . $result['error_message'] . ' on ' . $entity . '.' . $action
511 . (!empty($params['name']) ?
'( entity name ' . $params['name'] . ')' : '')
516 * Determine if an entity supports APIv3-based activation/de-activation.
517 * @param string $entity_type
520 * @throws \CiviCRM_API3_Exception
522 private function isActivationSupported(string $entity_type): bool {
523 if (!isset(Civi
::$statics[__CLASS__
][__FUNCTION__
][$entity_type])) {
524 $actions = civicrm_api3($entity_type, 'getactions', [])['values'];
525 Civi
::$statics[__CLASS__
][__FUNCTION__
][$entity_type] = FALSE;
526 if (in_array('create', $actions, TRUE) && in_array('getfields', $actions)) {
527 $fields = civicrm_api3($entity_type, 'getfields', ['action' => 'create'])['values'];
528 Civi
::$statics[__CLASS__
][__FUNCTION__
][$entity_type] = array_key_exists('is_active', $fields);
531 return Civi
::$statics[__CLASS__
][__FUNCTION__
][$entity_type];
535 * Load declarations into the class property.
537 * This picks it up from hooks and enabled components.
539 * @param array|null $modules
540 * Limit reconciliation specified modules.
542 protected function loadDeclarations($modules = NULL): void
{
544 // Exclude components if given a module name.
545 if (!$modules ||
$modules === ['civicrm']) {
546 foreach (CRM_Core_Component
::getEnabledComponents() as $component) {
547 $declarations = array_merge($declarations, $component->getManagedEntities());
550 CRM_Utils_Hook
::managed($declarations, $modules);
551 $this->validate($declarations);
552 $this->setDeclarations($declarations);
556 * Builds $this->managedActions array
558 * @param array|null $modules
560 protected function createPlan($modules = NULL): void
{
561 $where = $modules ?
[['module', 'IN', $modules]] : [];
562 $managedEntities = Managed
::get(FALSE)
566 foreach ($managedEntities as $managedEntity) {
567 $key = "{$managedEntity['module']}_{$managedEntity['name']}_{$managedEntity['entity_type']}";
568 // Set to disable or delete if module is disabled or missing - it will be overwritten below module is active.
569 $action = $this->isModuleDisabled($managedEntity['module']) ?
'disable' : 'delete';
570 $this->plan
[$key] = array_merge($managedEntity, ['managed_action' => $action]);
572 foreach ($this->declarations
as $declaration) {
573 $key = "{$declaration['module']}_{$declaration['name']}_{$declaration['entity']}";
574 if (isset($this->plan
[$key])) {
575 $this->plan
[$key]['params'] = $declaration['params'];
576 $this->plan
[$key]['managed_action'] = 'update';
577 $this->plan
[$key]['cleanup'] = $declaration['cleanup'] ??
NULL;
578 $this->plan
[$key]['update'] = $declaration['update'] ??
'always';
581 $this->plan
[$key] = [
582 'module' => $declaration['module'],
583 'name' => $declaration['name'],
584 'entity_type' => $declaration['entity'],
585 'managed_action' => 'create',
586 'params' => $declaration['params'],
587 'cleanup' => $declaration['cleanup'] ??
NULL,
588 'update' => $declaration['update'] ??
'always',