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 // Do not reconcile whilst we are in upgrade mode
124 if (CRM_Core_Config
::singleton()->isUpgradeMode() && !$ignoreUpgradeMode) {
127 $this->loadDeclarations();
128 if ($error = $this->validate($this->getDeclarations())) {
129 throw new CRM_Core_Exception($error);
131 $this->loadManagedEntityActions();
132 $this->reconcileEnabledModules();
133 $this->reconcileDisabledModules();
134 $this->reconcileUnknownModules();
138 * For all enabled modules, add new entities, update
139 * existing entities, and remove orphaned (stale) entities.
141 protected function reconcileEnabledModules(): void
{
142 // Note: any thing currently declared is necessarily from
143 // an active module -- because we got it from a hook!
145 // index by moduleName,name
146 $decls = $this->createDeclarationIndex($this->moduleIndex
, $this->getDeclarations());
147 foreach ($decls as $moduleName => $todos) {
148 if ($this->isModuleEnabled($moduleName)) {
149 $this->reconcileEnabledModule($moduleName);
155 * For one enabled module, add new entities, update existing entities,
156 * and remove orphaned (stale) entities.
158 * @param string $module
160 protected function reconcileEnabledModule(string $module): void
{
161 foreach ($this->getManagedEntitiesToUpdate(['module' => $module]) as $todo) {
162 $dao = new CRM_Core_DAO_Managed();
163 $dao->module
= $todo['module'];
164 $dao->name
= $todo['name'];
165 $dao->entity_type
= $todo['entity_type'];
166 $dao->entity_id
= $todo['entity_id'];
167 $dao->id
= $todo['id'];
168 $this->updateExistingEntity($dao, $todo);
171 foreach ($this->getManagedEntitiesToDelete(['module' => $module]) as $todo) {
172 $dao = new CRM_Core_DAO_Managed();
173 $dao->module
= $todo['module'];
174 $dao->name
= $todo['name'];
175 $dao->entity_type
= $todo['entity_type'];
176 $dao->id
= $todo['id'];
177 $dao->cleanup
= $todo['cleanup'];
178 $dao->entity_id
= $todo['entity_id'];
179 $this->removeStaleEntity($dao);
181 foreach ($this->getManagedEntitiesToCreate(['module' => $module]) as $todo) {
182 $this->insertNewEntity($todo);
187 * Get the managed entities to be created.
189 * @param array $filters
193 protected function getManagedEntitiesToCreate(array $filters = []): array {
194 return $this->getManagedEntities(array_merge($filters, ['managed_action' => 'create']));
198 * Get the managed entities to be created.
200 * @param array $filters
204 protected function getManagedEntitiesToUpdate(array $filters = []): array {
205 return $this->getManagedEntities(array_merge($filters, ['managed_action' => 'update']));
209 * Get the managed entities to be deleted.
211 * @param array $filters
215 protected function getManagedEntitiesToDelete(array $filters = []): array {
216 return $this->getManagedEntities(array_merge($filters, ['managed_action' => 'delete']));
220 * Get the managed entities that fit the criteria.
222 * @param array $filters
226 protected function getManagedEntities(array $filters = []): array {
228 foreach ($this->managedActions
as $actionKey => $action) {
229 foreach ($filters as $filterKey => $filterValue) {
230 if ($action[$filterKey] !== $filterValue) {
234 $return[$actionKey] = $action;
240 * For all disabled modules, disable any managed entities.
242 protected function reconcileDisabledModules() {
243 if (empty($this->moduleIndex
[FALSE])) {
247 $in = CRM_Core_DAO
::escapeStrings(array_keys($this->moduleIndex
[FALSE]));
248 $dao = new CRM_Core_DAO_Managed();
249 $dao->whereAdd("module in ($in)");
250 $dao->orderBy('id DESC');
252 while ($dao->fetch()) {
253 $this->disableEntity($dao);
259 * Remove any orphaned (stale) entities that are linked to
262 protected function reconcileUnknownModules() {
264 if (array_key_exists(0, $this->moduleIndex
) && is_array($this->moduleIndex
[0])) {
265 $knownModules = array_merge($knownModules, array_keys($this->moduleIndex
[0]));
267 if (array_key_exists(1, $this->moduleIndex
) && is_array($this->moduleIndex
[1])) {
268 $knownModules = array_merge($knownModules, array_keys($this->moduleIndex
[1]));
271 $dao = new CRM_Core_DAO_Managed();
272 if (!empty($knownModules)) {
273 $in = CRM_Core_DAO
::escapeStrings($knownModules);
274 $dao->whereAdd("module NOT IN ($in)");
275 $dao->orderBy('id DESC');
278 while ($dao->fetch()) {
279 $this->removeStaleEntity($dao);
284 * Create a new entity.
287 * Entity specification (per hook_civicrm_managedEntities).
289 protected function insertNewEntity($todo) {
290 $result = civicrm_api($todo['entity_type'], 'create', $todo['params']);
291 if (!empty($result['is_error'])) {
292 $this->onApiError($todo['entity_type'], 'create', $todo['params'], $result);
295 $dao = new CRM_Core_DAO_Managed();
296 $dao->module
= $todo['module'];
297 $dao->name
= $todo['name'];
298 $dao->entity_type
= $todo['entity_type'];
299 // A fatal error will result if there is no valid id but if
300 // this is v4 api we might need to access it via ->first().
301 $dao->entity_id
= $result['id'] ??
$result->first()['id'];
302 $dao->cleanup
= $todo['cleanup'] ??
NULL;
307 * Update an entity which is believed to exist.
309 * @param CRM_Core_DAO_Managed $dao
311 * Entity specification (per hook_civicrm_managedEntities).
313 protected function updateExistingEntity($dao, $todo) {
314 $policy = CRM_Utils_Array
::value('update', $todo, 'always');
315 $doUpdate = ($policy === 'always');
318 $defaults = ['id' => $dao->entity_id
];
319 if ($this->isActivationSupported($dao->entity_type
)) {
320 $defaults['is_active'] = 1;
322 $params = array_merge($defaults, $todo['params']);
324 $manager = CRM_Extension_System
::singleton()->getManager();
325 if ($dao->entity_type
=== 'Job' && !$manager->extensionIsBeingInstalledOrEnabled($dao->module
)) {
326 // Special treatment for scheduled jobs:
328 // If we're being called as part of enabling/installing a module then
329 // we want the default behaviour of setting is_active = 1.
331 // However, if we're just being called by a normal cache flush then we
332 // should not re-enable a job that an administrator has decided to disable.
334 // Without this logic there was a problem: site admin might disable
335 // a job, but then when there was a flush op, the job was re-enabled
336 // which can cause significant embarrassment, depending on the job
337 // ("Don't worry, sending mailings is disabled right now...").
338 unset($params['is_active']);
341 $result = civicrm_api($dao->entity_type
, 'create', $params);
342 if ($result['is_error']) {
343 $this->onApiError($dao->entity_type
, 'create', $params, $result);
347 if (isset($todo['cleanup'])) {
348 $dao->cleanup
= $todo['cleanup'];
354 * Update an entity which (a) is believed to exist and which (b) ought to be
357 * @param CRM_Core_DAO_Managed $dao
359 * @throws \CiviCRM_API3_Exception
361 protected function disableEntity($dao): void
{
362 $entity_type = $dao->entity_type
;
363 if ($this->isActivationSupported($entity_type)) {
364 // FIXME cascading for payproc types?
367 'id' => $dao->entity_id
,
370 $result = civicrm_api($dao->entity_type
, 'create', $params);
371 if ($result['is_error']) {
372 $this->onApiError($dao->entity_type
, 'create', $params, $result);
378 * Remove a stale entity (if policy allows).
380 * @param CRM_Core_DAO_Managed $dao
381 * @throws CRM_Core_Exception
383 protected function removeStaleEntity($dao) {
384 $policy = empty($dao->cleanup
) ?
'always' : $dao->cleanup
;
395 $getRefCount = civicrm_api3($dao->entity_type
, 'getrefcount', [
397 'id' => $dao->entity_id
,
401 foreach ($getRefCount['values'] as $refCount) {
402 $total +
= $refCount['count'];
405 $doDelete = ($total == 0);
409 throw new CRM_Core_Exception('Unrecognized cleanup policy: ' . $policy);
415 'id' => $dao->entity_id
,
417 $check = civicrm_api3($dao->entity_type
, 'get', $params);
418 if ((bool) $check['count']) {
419 $result = civicrm_api($dao->entity_type
, 'delete', $params);
420 if ($result['is_error']) {
421 if (isset($dao->name
)) {
422 $params['name'] = $dao->name
;
424 $this->onApiError($dao->entity_type
, 'delete', $params, $result);
427 CRM_Core_DAO
::executeQuery('DELETE FROM civicrm_managed WHERE id = %1', [
428 1 => [$dao->id
, 'Integer'],
438 protected function getDeclarations() {
439 return $this->declarations
;
443 * @param array $modules
444 * Array<CRM_Core_Module>.
447 * indexed by is_active,name
449 protected function createModuleIndex($modules) {
451 foreach ($modules as $module) {
452 $result[$module->is_active
][$module->name
] = $module;
458 * @param array $moduleIndex
459 * @param array $declarations
462 * indexed by module,name
464 protected function createDeclarationIndex($moduleIndex, $declarations) {
466 if (!isset($moduleIndex[TRUE])) {
469 foreach ($moduleIndex[TRUE] as $moduleName => $module) {
470 if ($module->is_active
) {
471 // need an empty array() for all active modules, even if there are no current $declarations
472 $result[$moduleName] = [];
475 foreach ($declarations as $declaration) {
476 $result[$declaration['module']][$declaration['name']] = $declaration;
482 * @param $declarations
484 * @return string|bool
485 * string on error, or FALSE
487 protected function validate($declarations) {
488 foreach ($declarations as $module => $declare) {
489 foreach (['name', 'module', 'entity', 'params'] as $key) {
490 if (empty($declare[$key])) {
491 $str = print_r($declare, TRUE);
492 return ts('Managed Entity (%1) is missing field "%2": %3', [$module, $key, $str]);
495 if (!$this->isModuleRecognised($declare['module'])) {
496 return ts('Entity declaration references invalid or inactive module name [%1]', [$declare['module']]);
503 * Is the module recognised (as an enabled or disabled extension in the system).
505 * @param string $module
509 protected function isModuleRecognised(string $module): bool {
510 return $this->isModuleDisabled($module) ||
$this->isModuleEnabled($module);
514 * Is the module enabled.
516 * @param string $module
520 protected function isModuleEnabled(string $module): bool {
521 return isset($this->moduleIndex
[TRUE][$module]);
525 * Is the module disabled.
527 * @param string $module
531 protected function isModuleDisabled(string $module): bool {
532 return isset($this->moduleIndex
[FALSE][$module]);
536 * @param array $declarations
540 protected function cleanDeclarations(array $declarations): array {
541 foreach ($declarations as $name => &$declare) {
542 if (!array_key_exists('name', $declare)) {
543 $declare['name'] = $name;
546 return $declarations;
550 * @param string $entity
551 * @param string $action
552 * @param array $params
553 * @param array $result
557 protected function onApiError($entity, $action, $params, $result) {
558 CRM_Core_Error
::debug_var('ManagedEntities_failed', [
564 throw new Exception('API error: ' . $result['error_message'] . ' on ' . $entity . '.' . $action
565 . (!empty($params['name']) ?
'( entity name ' . $params['name'] . ')' : '')
570 * Determine if an entity supports APIv3-based activation/de-activation.
571 * @param string $entity_type
574 * @throws \CiviCRM_API3_Exception
576 private function isActivationSupported(string $entity_type): bool {
577 if (!isset(Civi
::$statics[__CLASS__
][__FUNCTION__
][$entity_type])) {
578 $actions = civicrm_api3($entity_type, 'getactions', [])['values'];
579 Civi
::$statics[__CLASS__
][__FUNCTION__
][$entity_type] = FALSE;
580 if (in_array('create', $actions, TRUE) && in_array('getfields', $actions)) {
581 $fields = civicrm_api3($entity_type, 'getfields', ['action' => 'create'])['values'];
582 Civi
::$statics[__CLASS__
][__FUNCTION__
][$entity_type] = array_key_exists('is_active', $fields);
585 return Civi
::$statics[__CLASS__
][__FUNCTION__
][$entity_type];
589 * Load declarations into the class property.
591 * This picks it up from hooks and enabled components.
593 protected function loadDeclarations(): void
{
594 $this->declarations
= [];
595 foreach (CRM_Core_Component
::getEnabledComponents() as $component) {
596 $this->declarations
= array_merge($this->declarations
, $component->getManagedEntities());
598 CRM_Utils_Hook
::managed($this->declarations
);
599 $this->declarations
= $this->cleanDeclarations($this->declarations
);
602 protected function loadManagedEntityActions(): void
{
603 $managedEntities = Managed
::get(FALSE)->addSelect('*')->execute();
604 foreach ($managedEntities as $managedEntity) {
605 $key = "{$managedEntity['module']}_{$managedEntity['name']}_{$managedEntity['entity_type']}";
606 // Set to 'delete' - it will be overwritten below if it is to be updated.
608 $this->managedActions
[$key] = array_merge($managedEntity, ['managed_action' => $action]);
610 foreach ($this->declarations
as $declaration) {
611 $key = "{$declaration['module']}_{$declaration['name']}_{$declaration['entity']}";
612 if (isset($this->managedActions
[$key])) {
613 $this->managedActions
[$key]['params'] = $declaration['params'];
614 $this->managedActions
[$key]['managed_action'] = 'update';
615 $this->managedActions
[$key]['cleanup'] = $declaration['cleanup'] ??
NULL;
616 $this->managedActions
[$key]['update'] = $declaration['update'] ??
'always';
619 $this->managedActions
[$key] = [
620 'module' => $declaration['module'],
621 'name' => $declaration['name'],
622 'entity_type' => $declaration['entity'],
623 'managed_action' => 'create',
624 'params' => $declaration['params'],
625 'cleanup' => $declaration['cleanup'] ??
NULL,
626 'update' => $declaration['update'] ??
'always',