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