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;
34 * @return \CRM_Core_ManagedEntities
36 public static function singleton($fresh = FALSE) {
38 if ($fresh ||
!$singleton) {
39 $singleton = new CRM_Core_ManagedEntities(CRM_Core_Module
::getAll());
45 * Perform an asynchronous reconciliation when the transaction ends.
47 public static function scheduleReconciliation() {
48 CRM_Core_Transaction
::addCallback(
49 CRM_Core_Transaction
::PHASE_POST_COMMIT
,
51 CRM_Core_ManagedEntities
::singleton(TRUE)->reconcile();
54 'ManagedEntities::reconcile'
59 * @param array $modules
62 public function __construct(array $modules) {
63 $this->moduleIndex
= $this->createModuleIndex($modules);
67 * Read a managed entity using APIv3.
71 * @param string $moduleName
72 * The name of the module which declared entity.
74 * The symbolic name of the entity.
76 * API representation, or NULL if the entity does not exist
78 public function get($moduleName, $name) {
79 $dao = new CRM_Core_DAO_Managed();
80 $dao->module
= $moduleName;
82 if ($dao->find(TRUE)) {
84 'id' => $dao->entity_id
,
88 $result = civicrm_api3($dao->entity_type
, 'getsingle', $params);
90 catch (Exception
$e) {
91 $this->onApiError($dao->entity_type
, 'getsingle', $params, $result);
101 * Identify any enabled/disabled modules. Add new entities, update
102 * existing entities, and remove orphaned (stale) entities.
104 * @param array $modules
105 * Limits scope of reconciliation to specific module(s).
106 * @throws \CRM_Core_Exception
108 public function reconcile($modules = NULL) {
109 $modules = $modules ?
(array) $modules : NULL;
110 $declarations = $this->getDeclarations($modules);
111 $plan = $this->createPlan($declarations, $modules);
112 $this->reconcileEntities($plan);
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
120 public function revert(array $params) {
121 $mgd = new \
CRM_Core_DAO_Managed();
122 $mgd->copyValues($params);
124 $declarations = $this->getDeclarations([$mgd->module
]);
125 $declarations = CRM_Utils_Array
::findAll($declarations, [
126 'module' => $mgd->module
,
127 'name' => $mgd->name
,
128 'entity' => $mgd->entity_type
,
130 if ($mgd->id
&& isset($declarations[0])) {
131 $this->updateExistingEntity(['update' => 'always'] +
$declarations[0] +
$mgd->toArray());
138 * Take appropriate action on every managed entity.
140 * @param array[] $plan
142 private function reconcileEntities(array $plan): void
{
143 foreach ($this->filterPlanByAction($plan, 'update') as $item) {
144 $this->updateExistingEntity($item);
146 // reverse-order so that child entities are cleaned up before their parents
147 foreach (array_reverse($this->filterPlanByAction($plan, 'delete')) as $item) {
148 $this->removeStaleEntity($item);
150 foreach ($this->filterPlanByAction($plan, 'create') as $item) {
151 $this->insertNewEntity($item);
153 foreach ($this->filterPlanByAction($plan, 'disable') as $item) {
154 $this->disableEntity($item);
159 * Get the managed entities that fit the criteria.
161 * @param array[] $plan
162 * @param string $action
166 private function filterPlanByAction(array $plan, string $action): array {
167 return CRM_Utils_Array
::findAll($plan, ['managed_action' => $action]);
171 * Create a new entity.
174 * Entity specification (per hook_civicrm_managedEntities).
176 protected function insertNewEntity(array $item) {
177 $params = $item['params'];
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']);
184 $result = civicrm_api4($item['entity_type'], 'save', $params);
185 $id = $result->first()['id'];
189 $result = civicrm_api($item['entity_type'], 'create', $params);
190 if (!empty($result['is_error'])) {
191 $this->onApiError($item['entity_type'], 'create', $params, $result);
196 $dao = new CRM_Core_DAO_Managed();
197 $dao->module
= $item['module'];
198 $dao->name
= $item['name'];
199 $dao->entity_type
= $item['entity_type'];
200 $dao->entity_id
= $id;
201 $dao->cleanup
= $item['cleanup'] ??
NULL;
206 * Update an entity which is believed to exist.
209 * Entity specification (per hook_civicrm_managedEntities).
211 private function updateExistingEntity(array $item) {
212 $policy = $item['update'] ??
'always';
213 $doUpdate = ($policy === 'always');
215 if ($policy === 'unmodified') {
216 // If this is not an APIv4 managed entity, the entity_modidfied_date will always be null
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".');
220 $doUpdate = empty($item['entity_modified_date']);
223 if ($doUpdate && $item['params']['version'] == 3) {
224 $defaults = ['id' => $item['entity_id']];
225 if ($this->isActivationSupported($item['entity_type'])) {
226 $defaults['is_active'] = 1;
228 $params = array_merge($defaults, $item['params']);
230 $manager = CRM_Extension_System
::singleton()->getManager();
231 if ($item['entity_type'] === 'Job' && !$manager->extensionIsBeingInstalledOrEnabled($item['module'])) {
232 // Special treatment for scheduled jobs:
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.
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.
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']);
247 $result = civicrm_api($item['entity_type'], 'create', $params);
248 if ($result['is_error']) {
249 $this->onApiError($item['entity_type'], 'create', $params, $result);
252 elseif ($doUpdate && $item['params']['version'] == 4) {
253 $params = ['checkPermissions' => FALSE] +
$item['params'];
254 $params['values']['id'] = $item['entity_id'];
255 // 'match' param doesn't apply to "update" action
256 unset($params['match']);
257 civicrm_api4($item['entity_type'], 'update', $params);
260 if (isset($item['cleanup']) ||
$doUpdate) {
261 $dao = new CRM_Core_DAO_Managed();
262 $dao->id
= $item['id'];
263 $dao->cleanup
= $item['cleanup'] ??
NULL;
264 // Reset the `entity_modified_date` timestamp if reverting record.
265 $dao->entity_modified_date
= $doUpdate ?
'null' : NULL;
271 * Update an entity which (a) is believed to exist and which (b) ought to be
276 * @throws \CiviCRM_API3_Exception
278 protected function disableEntity(array $item): void
{
279 $entity_type = $item['entity_type'];
280 if ($this->isActivationSupported($entity_type)) {
281 // FIXME cascading for payproc types?
284 'id' => $item['entity_id'],
287 $result = civicrm_api($item['entity_type'], 'create', $params);
288 if ($result['is_error']) {
289 $this->onApiError($item['entity_type'], 'create', $params, $result);
291 // Reset the `entity_modified_date` timestamp to indicate that the entity has not been modified by the user.
292 $dao = new CRM_Core_DAO_Managed();
293 $dao->id
= $item['id'];
294 $dao->entity_modified_date
= 'null';
300 * Remove a stale entity (if policy allows).
303 * @throws CRM_Core_Exception
305 protected function removeStaleEntity(array $item) {
306 $policy = empty($item['cleanup']) ?
'always' : $item['cleanup'];
317 if (CRM_Core_BAO_Managed
::isApi4ManagedType($item['entity_type'])) {
318 $getRefCount = \Civi\Api4\Utils\CoreUtil
::getRefCount($item['entity_type'], $item['entity_id']);
321 $getRefCount = civicrm_api3($item['entity_type'], 'getrefcount', [
322 'id' => $item['entity_id'],
326 // FIXME: This extra counting should be unnecessary, because getRefCount only returns values if count > 0
328 foreach ($getRefCount as $refCount) {
329 $total +
= $refCount['count'];
332 $doDelete = ($total == 0);
336 throw new CRM_Core_Exception('Unrecognized cleanup policy: ' . $policy);
339 // APIv4 delete - deletion from `civicrm_managed` will be taken care of by
340 // CRM_Core_BAO_Managed::on_hook_civicrm_post()
341 if ($doDelete && CRM_Core_BAO_Managed
::isApi4ManagedType($item['entity_type'])) {
342 civicrm_api4($item['entity_type'], 'delete', [
343 'checkPermissions' => FALSE,
344 'where' => [['id', '=', $item['entity_id']]],
351 'id' => $item['entity_id'],
353 $check = civicrm_api3($item['entity_type'], 'get', $params);
354 if ($check['count']) {
355 $result = civicrm_api($item['entity_type'], 'delete', $params);
356 if ($result['is_error']) {
357 if (isset($item['name'])) {
358 $params['name'] = $item['name'];
360 $this->onApiError($item['entity_type'], 'delete', $params, $result);
363 CRM_Core_DAO
::executeQuery('DELETE FROM civicrm_managed WHERE id = %1', [
364 1 => [$item['id'], 'Integer'],
370 * @param array $modules
371 * Array<CRM_Core_Module>.
374 * indexed by is_active,name
376 protected function createModuleIndex($modules) {
378 foreach ($modules as $module) {
379 $result[$module->is_active
][$module->name
] = $module;
385 * @param array $moduleIndex
386 * @param array $declarations
389 * indexed by module,name
391 protected function createDeclarationIndex($moduleIndex, $declarations) {
393 if (!isset($moduleIndex[TRUE])) {
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
399 $result[$moduleName] = [];
402 foreach ($declarations as $declaration) {
403 $result[$declaration['module']][$declaration['name']] = $declaration;
409 * @param array $declarations
411 * @throws CRM_Core_Exception
413 protected function validate($declarations) {
414 foreach ($declarations as $module => $declare) {
415 foreach (['name', 'module', 'entity', 'params'] as $key) {
416 if (empty($declare[$key])) {
417 $str = print_r($declare, TRUE);
418 throw new CRM_Core_Exception(ts('Managed Entity (%1) is missing field "%2": %3', [$module, $key, $str]));
421 if (!$this->isModuleRecognised($declare['module'])) {
422 throw new CRM_Core_Exception(ts('Entity declaration references invalid or inactive module name [%1]', [$declare['module']]));
428 * Is the module recognised (as an enabled or disabled extension in the system).
430 * @param string $module
434 protected function isModuleRecognised(string $module): bool {
435 return $this->isModuleDisabled($module) ||
$this->isModuleEnabled($module);
439 * Is the module enabled.
441 * @param string $module
445 protected function isModuleEnabled(string $module): bool {
446 return isset($this->moduleIndex
[TRUE][$module]);
450 * Is the module disabled.
452 * @param string $module
456 protected function isModuleDisabled(string $module): bool {
457 return isset($this->moduleIndex
[FALSE][$module]);
461 * @param string $entity
462 * @param string $action
463 * @param array $params
464 * @param array $result
468 protected function onApiError($entity, $action, $params, $result) {
469 CRM_Core_Error
::debug_var('ManagedEntities_failed', [
475 throw new Exception('API error: ' . $result['error_message'] . ' on ' . $entity . '.' . $action
476 . (!empty($params['name']) ?
'( entity name ' . $params['name'] . ')' : '')
481 * Determine if an entity supports APIv3-based activation/de-activation.
482 * @param string $entity_type
485 * @throws \CiviCRM_API3_Exception
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);
496 return Civi
::$statics[__CLASS__
][__FUNCTION__
][$entity_type];
500 * Load managed entity declarations.
502 * This picks it up from hooks and enabled components.
504 * @param array|null $modules
505 * Limit reconciliation specified modules.
508 protected function getDeclarations($modules = NULL): array {
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());
516 CRM_Utils_Hook
::managed($declarations, $modules);
517 $this->validate($declarations);
518 foreach (array_keys($declarations) as $name) {
519 $declarations[$name] +
= ['name' => $name];
521 return $declarations;
525 * Builds $this->managedActions array
527 * @param array $declarations
528 * @param array|null $modules
531 protected function createPlan(array $declarations, $modules = NULL): array {
532 $where = $modules ?
[['module', 'IN', $modules]] : [];
533 $managedEntities = Managed
::get(FALSE)
537 foreach ($managedEntities as $managedEntity) {
538 $key = "{$managedEntity['module']}_{$managedEntity['name']}_{$managedEntity['entity_type']}";
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';
541 $plan[$key] = array_merge($managedEntity, ['managed_action' => $action]);
543 foreach ($declarations as $declaration) {
544 $key = "{$declaration['module']}_{$declaration['name']}_{$declaration['entity']}";
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';
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',