E2E_Core_ErrorTest - Demonstrate whether error-pages are well-formed
[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 * Plan for what to do with each managed entity.
33 *
34 * @var array
35 */
36 protected $plan = [];
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 }
79
80 /**
81 * Read a managed entity using APIv3.
82 *
83 * @deprecated
84 *
85 * @param string $moduleName
86 * The name of the module which declared entity.
87 * @param string $name
88 * The symbolic name of the entity.
89 * @return array|NULL
90 * API representation, or NULL if the entity does not exist
91 */
92 public function get($moduleName, $name) {
93 $dao = new CRM_Core_DAO_Managed();
94 $dao->module = $moduleName;
95 $dao->name = $name;
96 if ($dao->find(TRUE)) {
97 $params = [
98 'id' => $dao->entity_id,
99 ];
100 $result = NULL;
101 try {
102 $result = civicrm_api3($dao->entity_type, 'getsingle', $params);
103 }
104 catch (Exception $e) {
105 $this->onApiError($dao->entity_type, 'getsingle', $params, $result);
106 }
107 return $result;
108 }
109 else {
110 return NULL;
111 }
112 }
113
114 /**
115 * Identify any enabled/disabled modules. Add new entities, update
116 * existing entities, and remove orphaned (stale) entities.
117 *
118 * @param array $modules
119 * Limits scope of reconciliation to specific module(s).
120 * @throws \CRM_Core_Exception
121 */
122 public function reconcile($modules = NULL) {
123 $modules = $modules ? (array) $modules : NULL;
124 $this->loadDeclarations($modules);
125 $this->createPlan($modules);
126 $this->reconcileEntities();
127 }
128
129 /**
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
133 */
134 public function revert(array $params) {
135 $mgd = new \CRM_Core_DAO_Managed();
136 $mgd->copyValues($params);
137 $mgd->find(TRUE);
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,
143 ]);
144 if ($mgd->id && isset($declarations[0])) {
145 $this->updateExistingEntity(['update' => 'always'] + $declarations[0] + $mgd->toArray());
146 return TRUE;
147 }
148 return FALSE;
149 }
150
151 /**
152 * Take appropriate action on every managed entity.
153 */
154 private function reconcileEntities(): void {
155 foreach ($this->getManagedAction('update') as $item) {
156 $this->updateExistingEntity($item);
157 }
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);
161 }
162 foreach ($this->getManagedAction('create') as $item) {
163 $this->insertNewEntity($item);
164 }
165 foreach ($this->getManagedAction('disable') as $item) {
166 $this->disableEntity($item);
167 }
168 }
169
170 /**
171 * Get the managed entities that fit the criteria.
172 *
173 * @param string $action
174 *
175 * @return array
176 */
177 private function getManagedAction(string $action): array {
178 return CRM_Utils_Array::findAll($this->plan, ['managed_action' => $action]);
179 }
180
181 /**
182 * Create a new entity.
183 *
184 * @param array $item
185 * Entity specification (per hook_civicrm_managedEntities).
186 */
187 protected function insertNewEntity(array $item) {
188 $params = $item['params'];
189 // APIv4
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'];
197 }
198 // APIv3
199 else {
200 $result = civicrm_api($item['entity_type'], 'create', $params);
201 if (!empty($result['is_error'])) {
202 $this->onApiError($item['entity_type'], 'create', $params, $result);
203 }
204 $id = $result['id'];
205 }
206
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;
213 $dao->save();
214 }
215
216 /**
217 * Update an entity which is believed to exist.
218 *
219 * @param array $item
220 * Entity specification (per hook_civicrm_managedEntities).
221 */
222 private function updateExistingEntity(array $item) {
223 $policy = $item['update'] ?? 'always';
224 $doUpdate = ($policy === 'always');
225
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".');
230 }
231 $doUpdate = empty($item['entity_modified_date']);
232 }
233
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;
238 }
239 $params = array_merge($defaults, $item['params']);
240
241 $manager = CRM_Extension_System::singleton()->getManager();
242 if ($item['entity_type'] === 'Job' && !$manager->extensionIsBeingInstalledOrEnabled($item['module'])) {
243 // Special treatment for scheduled jobs:
244 //
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.
247 //
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.
250 //
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']);
256 }
257
258 $result = civicrm_api($item['entity_type'], 'create', $params);
259 if ($result['is_error']) {
260 $this->onApiError($item['entity_type'], 'create', $params, $result);
261 }
262 }
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);
269 }
270
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;
277 $dao->update();
278 }
279 }
280
281 /**
282 * Update an entity which (a) is believed to exist and which (b) ought to be
283 * inactive.
284 *
285 * @param array $item
286 *
287 * @throws \CiviCRM_API3_Exception
288 */
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?
293 $params = [
294 'version' => 3,
295 'id' => $item['entity_id'],
296 'is_active' => 0,
297 ];
298 $result = civicrm_api($item['entity_type'], 'create', $params);
299 if ($result['is_error']) {
300 $this->onApiError($item['entity_type'], 'create', $params, $result);
301 }
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';
306 $dao->update();
307 }
308 }
309
310 /**
311 * Remove a stale entity (if policy allows).
312 *
313 * @param array $item
314 * @throws CRM_Core_Exception
315 */
316 protected function removeStaleEntity(array $item) {
317 $policy = empty($item['cleanup']) ? 'always' : $item['cleanup'];
318 switch ($policy) {
319 case 'always':
320 $doDelete = TRUE;
321 break;
322
323 case 'never':
324 $doDelete = FALSE;
325 break;
326
327 case 'unused':
328 if (CRM_Core_BAO_Managed::isApi4ManagedType($item['entity_type'])) {
329 $getRefCount = \Civi\Api4\Utils\CoreUtil::getRefCount($item['entity_type'], $item['entity_id']);
330 }
331 else {
332 $getRefCount = civicrm_api3($item['entity_type'], 'getrefcount', [
333 'id' => $item['entity_id'],
334 ])['values'];
335 }
336
337 // FIXME: This extra counting should be unnecessary, because getRefCount only returns values if count > 0
338 $total = 0;
339 foreach ($getRefCount as $refCount) {
340 $total += $refCount['count'];
341 }
342
343 $doDelete = ($total == 0);
344 break;
345
346 default:
347 throw new CRM_Core_Exception('Unrecognized cleanup policy: ' . $policy);
348 }
349
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']]],
356 ]);
357 }
358 // APIv3 delete
359 elseif ($doDelete) {
360 $params = [
361 'version' => 3,
362 'id' => $item['entity_id'],
363 ];
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'];
370 }
371 $this->onApiError($item['entity_type'], 'delete', $params, $result);
372 }
373 }
374 CRM_Core_DAO::executeQuery('DELETE FROM civicrm_managed WHERE id = %1', [
375 1 => [$item['id'], 'Integer'],
376 ]);
377 }
378 }
379
380 /**
381 * Get declarations.
382 *
383 * @return array|null
384 */
385 protected function getDeclarations() {
386 return $this->declarations;
387 }
388
389 /**
390 * @param array $modules
391 * Array<CRM_Core_Module>.
392 *
393 * @return array
394 * indexed by is_active,name
395 */
396 protected function createModuleIndex($modules) {
397 $result = [];
398 foreach ($modules as $module) {
399 $result[$module->is_active][$module->name] = $module;
400 }
401 return $result;
402 }
403
404 /**
405 * @param array $moduleIndex
406 * @param array $declarations
407 *
408 * @return array
409 * indexed by module,name
410 */
411 protected function createDeclarationIndex($moduleIndex, $declarations) {
412 $result = [];
413 if (!isset($moduleIndex[TRUE])) {
414 return $result;
415 }
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] = [];
420 }
421 }
422 foreach ($declarations as $declaration) {
423 $result[$declaration['module']][$declaration['name']] = $declaration;
424 }
425 return $result;
426 }
427
428 /**
429 * @param array $declarations
430 *
431 * @throws CRM_Core_Exception
432 */
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]));
439 }
440 }
441 if (!$this->isModuleRecognised($declare['module'])) {
442 throw new CRM_Core_Exception(ts('Entity declaration references invalid or inactive module name [%1]', [$declare['module']]));
443 }
444 }
445 }
446
447 /**
448 * Is the module recognised (as an enabled or disabled extension in the system).
449 *
450 * @param string $module
451 *
452 * @return bool
453 */
454 protected function isModuleRecognised(string $module): bool {
455 return $this->isModuleDisabled($module) || $this->isModuleEnabled($module);
456 }
457
458 /**
459 * Is the module enabled.
460 *
461 * @param string $module
462 *
463 * @return bool
464 */
465 protected function isModuleEnabled(string $module): bool {
466 return isset($this->moduleIndex[TRUE][$module]);
467 }
468
469 /**
470 * Is the module disabled.
471 *
472 * @param string $module
473 *
474 * @return bool
475 */
476 protected function isModuleDisabled(string $module): bool {
477 return isset($this->moduleIndex[FALSE][$module]);
478 }
479
480 /**
481 * @param array $declarations
482 * @param string $moduleName
483 * Filter to a single module.
484 */
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;
491 }
492 }
493 }
494
495 /**
496 * @param string $entity
497 * @param string $action
498 * @param array $params
499 * @param array $result
500 *
501 * @throws Exception
502 */
503 protected function onApiError($entity, $action, $params, $result) {
504 CRM_Core_Error::debug_var('ManagedEntities_failed', [
505 'entity' => $entity,
506 'action' => $action,
507 'params' => $params,
508 'result' => $result,
509 ]);
510 throw new Exception('API error: ' . $result['error_message'] . ' on ' . $entity . '.' . $action
511 . (!empty($params['name']) ? '( entity name ' . $params['name'] . ')' : '')
512 );
513 }
514
515 /**
516 * Determine if an entity supports APIv3-based activation/de-activation.
517 * @param string $entity_type
518 *
519 * @return bool
520 * @throws \CiviCRM_API3_Exception
521 */
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);
529 }
530 }
531 return Civi::$statics[__CLASS__][__FUNCTION__][$entity_type];
532 }
533
534 /**
535 * Load declarations into the class property.
536 *
537 * This picks it up from hooks and enabled components.
538 *
539 * @param array|null $modules
540 * Limit reconciliation specified modules.
541 */
542 protected function loadDeclarations($modules = NULL): void {
543 $declarations = [];
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());
548 }
549 }
550 CRM_Utils_Hook::managed($declarations, $modules);
551 $this->validate($declarations);
552 $this->setDeclarations($declarations);
553 }
554
555 /**
556 * Builds $this->managedActions array
557 *
558 * @param array|null $modules
559 */
560 protected function createPlan($modules = NULL): void {
561 $where = $modules ? [['module', 'IN', $modules]] : [];
562 $managedEntities = Managed::get(FALSE)
563 ->setWhere($where)
564 ->execute();
565 $this->plan = [];
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]);
571 }
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';
579 }
580 else {
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',
589 ];
590 }
591 }
592 }
593
594 }