Add experimental setting `enableBackgroundQueue`
[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 * Get an instance.
33 * @param bool $fresh
34 * @return \CRM_Core_ManagedEntities
35 */
36 public static function singleton($fresh = FALSE) {
37 static $singleton;
38 if ($fresh || !$singleton) {
39 $singleton = new CRM_Core_ManagedEntities(CRM_Core_Module::getAll());
40 }
41 return $singleton;
42 }
43
44 /**
45 * Perform an asynchronous reconciliation when the transaction ends.
46 */
47 public static function scheduleReconciliation() {
48 CRM_Core_Transaction::addCallback(
49 CRM_Core_Transaction::PHASE_POST_COMMIT,
50 function () {
51 CRM_Core_ManagedEntities::singleton(TRUE)->reconcile();
52 },
53 [],
54 'ManagedEntities::reconcile'
55 );
56 }
57
58 /**
59 * @param array $modules
60 * CRM_Core_Module.
61 */
62 public function __construct(array $modules) {
63 $this->moduleIndex = $this->createModuleIndex($modules);
64 }
65
66 /**
67 * Read a managed entity using APIv3.
68 *
69 * @deprecated
70 *
71 * @param string $moduleName
72 * The name of the module which declared entity.
73 * @param string $name
74 * The symbolic name of the entity.
75 * @return array|NULL
76 * API representation, or NULL if the entity does not exist
77 */
78 public function get($moduleName, $name) {
79 $dao = new CRM_Core_DAO_Managed();
80 $dao->module = $moduleName;
81 $dao->name = $name;
82 if ($dao->find(TRUE)) {
83 $params = [
84 'id' => $dao->entity_id,
85 ];
86 $result = NULL;
87 try {
88 $result = civicrm_api3($dao->entity_type, 'getsingle', $params);
89 }
90 catch (Exception $e) {
91 $this->onApiError($dao->entity_type, 'getsingle', $params, $result);
92 }
93 return $result;
94 }
95 else {
96 return NULL;
97 }
98 }
99
100 /**
101 * Identify any enabled/disabled modules. Add new entities, update
102 * existing entities, and remove orphaned (stale) entities.
103 *
104 * @param array $modules
105 * Limits scope of reconciliation to specific module(s).
106 * @throws \CRM_Core_Exception
107 */
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);
113 }
114
115 /**
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
119 */
120 public function revert(array $params) {
121 $mgd = new \CRM_Core_DAO_Managed();
122 $mgd->copyValues($params);
123 $mgd->find(TRUE);
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,
129 ]);
130 if ($mgd->id && isset($declarations[0])) {
131 $this->updateExistingEntity(['update' => 'always'] + $declarations[0] + $mgd->toArray());
132 return TRUE;
133 }
134 return FALSE;
135 }
136
137 /**
138 * Take appropriate action on every managed entity.
139 *
140 * @param array[] $plan
141 */
142 private function reconcileEntities(array $plan): void {
143 foreach ($this->filterPlanByAction($plan, 'update') as $item) {
144 $this->updateExistingEntity($item);
145 }
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);
149 }
150 foreach ($this->filterPlanByAction($plan, 'create') as $item) {
151 $this->insertNewEntity($item);
152 }
153 foreach ($this->filterPlanByAction($plan, 'disable') as $item) {
154 $this->disableEntity($item);
155 }
156 }
157
158 /**
159 * Get the managed entities that fit the criteria.
160 *
161 * @param array[] $plan
162 * @param string $action
163 *
164 * @return array
165 */
166 private function filterPlanByAction(array $plan, string $action): array {
167 return CRM_Utils_Array::findAll($plan, ['managed_action' => $action]);
168 }
169
170 /**
171 * Create a new entity.
172 *
173 * @param array $item
174 * Entity specification (per hook_civicrm_managedEntities).
175 */
176 protected function insertNewEntity(array $item) {
177 $params = $item['params'];
178 // APIv4
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'];
186 }
187 // APIv3
188 else {
189 $result = civicrm_api($item['entity_type'], 'create', $params);
190 if (!empty($result['is_error'])) {
191 $this->onApiError($item['entity_type'], 'create', $params, $result);
192 }
193 $id = $result['id'];
194 }
195
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;
202 $dao->save();
203 }
204
205 /**
206 * Update an entity which is believed to exist.
207 *
208 * @param array $item
209 * Entity specification (per hook_civicrm_managedEntities).
210 */
211 private function updateExistingEntity(array $item) {
212 $policy = $item['update'] ?? 'always';
213 $doUpdate = ($policy === 'always');
214
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".');
219 }
220 $doUpdate = empty($item['entity_modified_date']);
221 }
222
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;
227 }
228 $params = array_merge($defaults, $item['params']);
229
230 $manager = CRM_Extension_System::singleton()->getManager();
231 if ($item['entity_type'] === 'Job' && !$manager->extensionIsBeingInstalledOrEnabled($item['module'])) {
232 // Special treatment for scheduled jobs:
233 //
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.
236 //
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.
239 //
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']);
245 }
246
247 $result = civicrm_api($item['entity_type'], 'create', $params);
248 if ($result['is_error']) {
249 $this->onApiError($item['entity_type'], 'create', $params, $result);
250 }
251 }
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);
258 }
259
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;
266 $dao->update();
267 }
268 }
269
270 /**
271 * Update an entity which (a) is believed to exist and which (b) ought to be
272 * inactive.
273 *
274 * @param array $item
275 *
276 * @throws \CiviCRM_API3_Exception
277 */
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?
282 $params = [
283 'version' => 3,
284 'id' => $item['entity_id'],
285 'is_active' => 0,
286 ];
287 $result = civicrm_api($item['entity_type'], 'create', $params);
288 if ($result['is_error']) {
289 $this->onApiError($item['entity_type'], 'create', $params, $result);
290 }
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';
295 $dao->update();
296 }
297 }
298
299 /**
300 * Remove a stale entity (if policy allows).
301 *
302 * @param array $item
303 * @throws CRM_Core_Exception
304 */
305 protected function removeStaleEntity(array $item) {
306 $policy = empty($item['cleanup']) ? 'always' : $item['cleanup'];
307 switch ($policy) {
308 case 'always':
309 $doDelete = TRUE;
310 break;
311
312 case 'never':
313 $doDelete = FALSE;
314 break;
315
316 case 'unused':
317 if (CRM_Core_BAO_Managed::isApi4ManagedType($item['entity_type'])) {
318 $getRefCount = \Civi\Api4\Utils\CoreUtil::getRefCount($item['entity_type'], $item['entity_id']);
319 }
320 else {
321 $getRefCount = civicrm_api3($item['entity_type'], 'getrefcount', [
322 'id' => $item['entity_id'],
323 ])['values'];
324 }
325
326 // FIXME: This extra counting should be unnecessary, because getRefCount only returns values if count > 0
327 $total = 0;
328 foreach ($getRefCount as $refCount) {
329 $total += $refCount['count'];
330 }
331
332 $doDelete = ($total == 0);
333 break;
334
335 default:
336 throw new CRM_Core_Exception('Unrecognized cleanup policy: ' . $policy);
337 }
338
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']]],
345 ]);
346 }
347 // APIv3 delete
348 elseif ($doDelete) {
349 $params = [
350 'version' => 3,
351 'id' => $item['entity_id'],
352 ];
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'];
359 }
360 $this->onApiError($item['entity_type'], 'delete', $params, $result);
361 }
362 }
363 CRM_Core_DAO::executeQuery('DELETE FROM civicrm_managed WHERE id = %1', [
364 1 => [$item['id'], 'Integer'],
365 ]);
366 }
367 }
368
369 /**
370 * @param array $modules
371 * Array<CRM_Core_Module>.
372 *
373 * @return array
374 * indexed by is_active,name
375 */
376 protected function createModuleIndex($modules) {
377 $result = [];
378 foreach ($modules as $module) {
379 $result[$module->is_active][$module->name] = $module;
380 }
381 return $result;
382 }
383
384 /**
385 * @param array $moduleIndex
386 * @param array $declarations
387 *
388 * @return array
389 * indexed by module,name
390 */
391 protected function createDeclarationIndex($moduleIndex, $declarations) {
392 $result = [];
393 if (!isset($moduleIndex[TRUE])) {
394 return $result;
395 }
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] = [];
400 }
401 }
402 foreach ($declarations as $declaration) {
403 $result[$declaration['module']][$declaration['name']] = $declaration;
404 }
405 return $result;
406 }
407
408 /**
409 * @param array $declarations
410 *
411 * @throws CRM_Core_Exception
412 */
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]));
419 }
420 }
421 if (!$this->isModuleRecognised($declare['module'])) {
422 throw new CRM_Core_Exception(ts('Entity declaration references invalid or inactive module name [%1]', [$declare['module']]));
423 }
424 }
425 }
426
427 /**
428 * Is the module recognised (as an enabled or disabled extension in the system).
429 *
430 * @param string $module
431 *
432 * @return bool
433 */
434 protected function isModuleRecognised(string $module): bool {
435 return $this->isModuleDisabled($module) || $this->isModuleEnabled($module);
436 }
437
438 /**
439 * Is the module enabled.
440 *
441 * @param string $module
442 *
443 * @return bool
444 */
445 protected function isModuleEnabled(string $module): bool {
446 return isset($this->moduleIndex[TRUE][$module]);
447 }
448
449 /**
450 * Is the module disabled.
451 *
452 * @param string $module
453 *
454 * @return bool
455 */
456 protected function isModuleDisabled(string $module): bool {
457 return isset($this->moduleIndex[FALSE][$module]);
458 }
459
460 /**
461 * @param string $entity
462 * @param string $action
463 * @param array $params
464 * @param array $result
465 *
466 * @throws Exception
467 */
468 protected function onApiError($entity, $action, $params, $result) {
469 CRM_Core_Error::debug_var('ManagedEntities_failed', [
470 'entity' => $entity,
471 'action' => $action,
472 'params' => $params,
473 'result' => $result,
474 ]);
475 throw new Exception('API error: ' . $result['error_message'] . ' on ' . $entity . '.' . $action
476 . (!empty($params['name']) ? '( entity name ' . $params['name'] . ')' : '')
477 );
478 }
479
480 /**
481 * Determine if an entity supports APIv3-based activation/de-activation.
482 * @param string $entity_type
483 *
484 * @return bool
485 * @throws \CiviCRM_API3_Exception
486 */
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);
494 }
495 }
496 return Civi::$statics[__CLASS__][__FUNCTION__][$entity_type];
497 }
498
499 /**
500 * Load managed entity declarations.
501 *
502 * This picks it up from hooks and enabled components.
503 *
504 * @param array|null $modules
505 * Limit reconciliation specified modules.
506 * @return array[]
507 */
508 protected function getDeclarations($modules = NULL): array {
509 $declarations = [];
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());
514 }
515 }
516 CRM_Utils_Hook::managed($declarations, $modules);
517 $this->validate($declarations);
518 foreach (array_keys($declarations) as $name) {
519 $declarations[$name] += ['name' => $name];
520 }
521 return $declarations;
522 }
523
524 /**
525 * Builds $this->managedActions array
526 *
527 * @param array $declarations
528 * @param array|null $modules
529 * @return array[]
530 */
531 protected function createPlan(array $declarations, $modules = NULL): array {
532 $where = $modules ? [['module', 'IN', $modules]] : [];
533 $managedEntities = Managed::get(FALSE)
534 ->setWhere($where)
535 ->execute();
536 $plan = [];
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]);
542 }
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';
550 }
551 else {
552 $plan[$key] = [
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',
560 ];
561 }
562 }
563 return $plan;
564 }
565
566 }