Merge pull request #21426 from eileenmcnaughton/api4
[civicrm-core.git] / CRM / Core / ManagedEntities.php
CommitLineData
6a488035
TO
1<?php
2
3/**
4 * The ManagedEntities system allows modules to add records to the database
5 * declaratively. Those records will be automatically inserted, updated,
6 * deactivated, and deleted in tandem with their modules.
7 */
8class CRM_Core_ManagedEntities {
1f103dc4 9
4b62bc4f
EM
10 /**
11 * Get clean up options.
12 *
13 * @return array
14 */
1f103dc4 15 public static function getCleanupOptions() {
be2fb01f 16 return [
1f103dc4
TO
17 'always' => ts('Always'),
18 'never' => ts('Never'),
19 'unused' => ts('If Unused'),
be2fb01f 20 ];
1f103dc4
TO
21 }
22
6a488035 23 /**
88718db2
TO
24 * @var array
25 * Array($status => array($name => CRM_Core_Module)).
6a488035 26 */
e9b95545 27 protected $moduleIndex;
6a488035
TO
28
29 /**
88718db2
TO
30 * @var array
31 * List of all entity declarations.
32 * @see CRM_Utils_Hook::managed()
6a488035 33 */
e9b95545 34 protected $declarations;
6a488035
TO
35
36 /**
d09edf64 37 * Get an instance.
ba3228d1
EM
38 * @param bool $fresh
39 * @return \CRM_Core_ManagedEntities
6a488035
TO
40 */
41 public static function singleton($fresh = FALSE) {
42 static $singleton;
43 if ($fresh || !$singleton) {
e9b95545 44 $singleton = new CRM_Core_ManagedEntities(CRM_Core_Module::getAll(), NULL);
6a488035
TO
45 }
46 return $singleton;
47 }
48
9c19292b
TO
49 /**
50 * Perform an asynchronous reconciliation when the transaction ends.
51 */
ba3228d1 52 public static function scheduleReconciliation() {
9c19292b
TO
53 CRM_Core_Transaction::addCallback(
54 CRM_Core_Transaction::PHASE_POST_COMMIT,
55 function () {
e70a7fc0 56 CRM_Core_ManagedEntities::singleton(TRUE)->reconcile();
9c19292b 57 },
be2fb01f 58 [],
9c19292b
TO
59 'ManagedEntities::reconcile'
60 );
61 }
62
6a488035 63 /**
6a0b768e
TO
64 * @param array $modules
65 * CRM_Core_Module.
66 * @param array $declarations
67 * Per hook_civicrm_managed.
6a488035
TO
68 */
69 public function __construct($modules, $declarations) {
85917c3b 70 $this->moduleIndex = $this->createModuleIndex($modules);
e9b95545
TO
71
72 if ($declarations !== NULL) {
85917c3b 73 $this->declarations = $this->cleanDeclarations($declarations);
0db6c3e1
TO
74 }
75 else {
e9b95545
TO
76 $this->declarations = NULL;
77 }
6a488035
TO
78 }
79
80 /**
88718db2 81 * Read a managed entity using APIv3.
378e2654 82 *
88718db2
TO
83 * @param string $moduleName
84 * The name of the module which declared entity.
85 * @param string $name
86 * The symbolic name of the entity.
72b3a70c
CW
87 * @return array|NULL
88 * API representation, or NULL if the entity does not exist
6a488035
TO
89 */
90 public function get($moduleName, $name) {
91 $dao = new CRM_Core_DAO_Managed();
92 $dao->module = $moduleName;
93 $dao->name = $name;
94 if ($dao->find(TRUE)) {
be2fb01f 95 $params = [
6a488035 96 'id' => $dao->entity_id,
be2fb01f 97 ];
bbf66e9c 98 $result = NULL;
637ea2cf
E
99 try {
100 $result = civicrm_api3($dao->entity_type, 'getsingle', $params);
101 }
102 catch (Exception $e) {
e9b95545 103 $this->onApiError($dao->entity_type, 'getsingle', $params, $result);
6a488035 104 }
637ea2cf 105 return $result;
0db6c3e1
TO
106 }
107 else {
6a488035
TO
108 return NULL;
109 }
110 }
111
88718db2
TO
112 /**
113 * Identify any enabled/disabled modules. Add new entities, update
114 * existing entities, and remove orphaned (stale) entities.
912511a3 115 * @param bool $ignoreUpgradeMode
88718db2
TO
116 *
117 * @throws Exception
118 */
912511a3
SL
119 public function reconcile($ignoreUpgradeMode = FALSE) {
120 // Do not reconcile whilst we are in upgrade mode
121 if (CRM_Core_Config::singleton()->isUpgradeMode() && !$ignoreUpgradeMode) {
122 return;
123 }
e9b95545 124 if ($error = $this->validate($this->getDeclarations())) {
6a488035
TO
125 throw new Exception($error);
126 }
127 $this->reconcileEnabledModules();
128 $this->reconcileDisabledModules();
129 $this->reconcileUnknownModules();
130 }
131
88718db2
TO
132 /**
133 * For all enabled modules, add new entities, update
134 * existing entities, and remove orphaned (stale) entities.
135 *
136 * @throws Exception
137 */
6a488035
TO
138 public function reconcileEnabledModules() {
139 // Note: any thing currently declared is necessarily from
140 // an active module -- because we got it from a hook!
141
142 // index by moduleName,name
85917c3b 143 $decls = $this->createDeclarationIndex($this->moduleIndex, $this->getDeclarations());
6a488035
TO
144 foreach ($decls as $moduleName => $todos) {
145 if (isset($this->moduleIndex[TRUE][$moduleName])) {
146 $this->reconcileEnabledModule($this->moduleIndex[TRUE][$moduleName], $todos);
0db6c3e1
TO
147 }
148 elseif (isset($this->moduleIndex[FALSE][$moduleName])) {
6a488035 149 // do nothing -- module should get swept up later
0db6c3e1
TO
150 }
151 else {
6a488035
TO
152 throw new Exception("Entity declaration references invalid or inactive module name [$moduleName]");
153 }
154 }
155 }
156
157 /**
88718db2
TO
158 * For one enabled module, add new entities, update existing entities,
159 * and remove orphaned (stale) entities.
6a488035 160 *
88718db2 161 * @param \CRM_Core_Module $module
5a4f6742 162 * @param array $todos
88718db2
TO
163 * List of entities currently declared by this module.
164 * array(string $name => array $entityDef).
6a488035
TO
165 */
166 public function reconcileEnabledModule(CRM_Core_Module $module, $todos) {
167 $dao = new CRM_Core_DAO_Managed();
168 $dao->module = $module->name;
169 $dao->find();
170 while ($dao->fetch()) {
3bd831aa 171 if (isset($todos[$dao->name]) && $todos[$dao->name]) {
6a488035 172 // update existing entity; remove from $todos
7cddb4ae 173 $this->updateExistingEntity($dao, $todos[$dao->name]);
6a488035 174 unset($todos[$dao->name]);
0db6c3e1
TO
175 }
176 else {
6a488035 177 // remove stale entity; not in $todos
bbf66e9c 178 $this->removeStaleEntity($dao);
6a488035
TO
179 }
180 }
181
182 // create new entities from leftover $todos
183 foreach ($todos as $name => $todo) {
7cddb4ae 184 $this->insertNewEntity($todo);
6a488035
TO
185 }
186 }
187
88718db2
TO
188 /**
189 * For all disabled modules, disable any managed entities.
190 */
6a488035
TO
191 public function reconcileDisabledModules() {
192 if (empty($this->moduleIndex[FALSE])) {
193 return;
194 }
195
196 $in = CRM_Core_DAO::escapeStrings(array_keys($this->moduleIndex[FALSE]));
197 $dao = new CRM_Core_DAO_Managed();
198 $dao->whereAdd("module in ($in)");
04b08baa 199 $dao->orderBy('id DESC');
6a488035
TO
200 $dao->find();
201 while ($dao->fetch()) {
7cddb4ae
TO
202 $this->disableEntity($dao);
203
6a488035
TO
204 }
205 }
206
88718db2
TO
207 /**
208 * Remove any orphaned (stale) entities that are linked to
209 * unknown modules.
210 */
6a488035 211 public function reconcileUnknownModules() {
be2fb01f 212 $knownModules = [];
6a488035
TO
213 if (array_key_exists(0, $this->moduleIndex) && is_array($this->moduleIndex[0])) {
214 $knownModules = array_merge($knownModules, array_keys($this->moduleIndex[0]));
215 }
216 if (array_key_exists(1, $this->moduleIndex) && is_array($this->moduleIndex[1])) {
217 $knownModules = array_merge($knownModules, array_keys($this->moduleIndex[1]));
6a488035
TO
218 }
219
220 $dao = new CRM_Core_DAO_Managed();
221 if (!empty($knownModules)) {
222 $in = CRM_Core_DAO::escapeStrings($knownModules);
223 $dao->whereAdd("module NOT IN ($in)");
04b08baa 224 $dao->orderBy('id DESC');
6a488035
TO
225 }
226 $dao->find();
227 while ($dao->fetch()) {
bbf66e9c
TO
228 $this->removeStaleEntity($dao);
229 }
230 }
6a488035 231
7cddb4ae 232 /**
88718db2 233 * Create a new entity.
7cddb4ae 234 *
6a0b768e
TO
235 * @param array $todo
236 * Entity specification (per hook_civicrm_managedEntities).
7cddb4ae 237 */
d80c6631 238 protected function insertNewEntity($todo) {
7cddb4ae 239 $result = civicrm_api($todo['entity'], 'create', $todo['params']);
ef902ada 240 if (!empty($result['is_error'])) {
e9b95545 241 $this->onApiError($todo['entity'], 'create', $todo['params'], $result);
7cddb4ae
TO
242 }
243
244 $dao = new CRM_Core_DAO_Managed();
245 $dao->module = $todo['module'];
246 $dao->name = $todo['name'];
247 $dao->entity_type = $todo['entity'];
be76e704 248 // A fatal error will result if there is no valid id but if
249 // this is v4 api we might need to access it via ->first().
250 $dao->entity_id = $result['id'] ?? $result->first()['id'];
9c1bc317 251 $dao->cleanup = $todo['cleanup'] ?? NULL;
7cddb4ae
TO
252 $dao->save();
253 }
254
255 /**
20429eb9 256 * Update an entity which is believed to exist.
7cddb4ae
TO
257 *
258 * @param CRM_Core_DAO_Managed $dao
6a0b768e
TO
259 * @param array $todo
260 * Entity specification (per hook_civicrm_managedEntities).
7cddb4ae
TO
261 */
262 public function updateExistingEntity($dao, $todo) {
0dd54586 263 $policy = CRM_Utils_Array::value('update', $todo, 'always');
35c1c211 264 $doUpdate = ($policy === 'always');
0dd54586
TO
265
266 if ($doUpdate) {
3cf02556
TO
267 $defaults = ['id' => $dao->entity_id];
268 if ($this->isActivationSupported($dao->entity_type)) {
269 $defaults['is_active'] = 1;
270 }
0dd54586 271 $params = array_merge($defaults, $todo['params']);
20429eb9
RL
272
273 $manager = CRM_Extension_System::singleton()->getManager();
274 if ($dao->entity_type === 'Job' && !$manager->extensionIsBeingInstalledOrEnabled($dao->module)) {
275 // Special treatment for scheduled jobs:
276 //
277 // If we're being called as part of enabling/installing a module then
278 // we want the default behaviour of setting is_active = 1.
279 //
280 // However, if we're just being called by a normal cache flush then we
281 // should not re-enable a job that an administrator has decided to disable.
282 //
283 // Without this logic there was a problem: site admin might disable
284 // a job, but then when there was a flush op, the job was re-enabled
285 // which can cause significant embarrassment, depending on the job
286 // ("Don't worry, sending mailings is disabled right now...").
287 unset($params['is_active']);
288 }
289
0dd54586
TO
290 $result = civicrm_api($dao->entity_type, 'create', $params);
291 if ($result['is_error']) {
353ffa53 292 $this->onApiError($dao->entity_type, 'create', $params, $result);
0dd54586 293 }
7cddb4ae 294 }
1f103dc4
TO
295
296 if (isset($todo['cleanup'])) {
297 $dao->cleanup = $todo['cleanup'];
298 $dao->update();
299 }
7cddb4ae
TO
300 }
301
302 /**
303 * Update an entity which (a) is believed to exist and which (b) ought to be
304 * inactive.
305 *
306 * @param CRM_Core_DAO_Managed $dao
8b91d849 307 *
308 * @throws \CiviCRM_API3_Exception
7cddb4ae 309 */
d80c6631 310 protected function disableEntity($dao): void {
fc625166
TO
311 $entity_type = $dao->entity_type;
312 if ($this->isActivationSupported($entity_type)) {
7cddb4ae 313 // FIXME cascading for payproc types?
be2fb01f 314 $params = [
7cddb4ae
TO
315 'version' => 3,
316 'id' => $dao->entity_id,
317 'is_active' => 0,
be2fb01f 318 ];
7cddb4ae
TO
319 $result = civicrm_api($dao->entity_type, 'create', $params);
320 if ($result['is_error']) {
353ffa53 321 $this->onApiError($dao->entity_type, 'create', $params, $result);
7cddb4ae
TO
322 }
323 }
324 }
325
bbf66e9c 326 /**
88718db2 327 * Remove a stale entity (if policy allows).
bbf66e9c
TO
328 *
329 * @param CRM_Core_DAO_Managed $dao
88718db2 330 * @throws Exception
bbf66e9c 331 */
d80c6631 332 protected function removeStaleEntity($dao) {
1f103dc4 333 $policy = empty($dao->cleanup) ? 'always' : $dao->cleanup;
378e2654
TO
334 switch ($policy) {
335 case 'always':
336 $doDelete = TRUE;
337 break;
ea100cb5 338
378e2654
TO
339 case 'never':
340 $doDelete = FALSE;
341 break;
ea100cb5 342
378e2654 343 case 'unused':
be2fb01f 344 $getRefCount = civicrm_api3($dao->entity_type, 'getrefcount', [
378e2654 345 'debug' => 1,
21dfd5f5 346 'id' => $dao->entity_id,
be2fb01f 347 ]);
378e2654
TO
348
349 $total = 0;
350 foreach ($getRefCount['values'] as $refCount) {
351 $total += $refCount['count'];
352 }
353
354 $doDelete = ($total == 0);
355 break;
ea100cb5 356
378e2654
TO
357 default:
358 throw new \Exception('Unrecognized cleanup policy: ' . $policy);
359 }
bbf66e9c 360
1f103dc4 361 if ($doDelete) {
be2fb01f 362 $params = [
1f103dc4
TO
363 'version' => 3,
364 'id' => $dao->entity_id,
be2fb01f 365 ];
a60c0bc8
SL
366 $check = civicrm_api3($dao->entity_type, 'get', $params);
367 if ((bool) $check['count']) {
368 $result = civicrm_api($dao->entity_type, 'delete', $params);
369 if ($result['is_error']) {
9f4f065a
MW
370 if (isset($dao->name)) {
371 $params['name'] = $dao->name;
372 }
a60c0bc8
SL
373 $this->onApiError($dao->entity_type, 'delete', $params, $result);
374 }
a60c0bc8 375 }
be2fb01f
CW
376 CRM_Core_DAO::executeQuery('DELETE FROM civicrm_managed WHERE id = %1', [
377 1 => [$dao->id, 'Integer'],
378 ]);
1f103dc4 379 }
6a488035
TO
380 }
381
2e2605fe
EM
382 /**
383 * Get declarations.
384 *
385 * @return array|null
386 */
d80c6631 387 protected function getDeclarations() {
e9b95545 388 if ($this->declarations === NULL) {
be2fb01f 389 $this->declarations = [];
e9b95545
TO
390 foreach (CRM_Core_Component::getEnabledComponents() as $component) {
391 /** @var CRM_Core_Component_Info $component */
392 $this->declarations = array_merge($this->declarations, $component->getManagedEntities());
393 }
394 CRM_Utils_Hook::managed($this->declarations);
85917c3b 395 $this->declarations = $this->cleanDeclarations($this->declarations);
e9b95545
TO
396 }
397 return $this->declarations;
398 }
399
6a488035 400 /**
88718db2
TO
401 * @param array $modules
402 * Array<CRM_Core_Module>.
77b97be7 403 *
a6c01b45
CW
404 * @return array
405 * indexed by is_active,name
6a488035 406 */
85917c3b 407 protected function createModuleIndex($modules) {
be2fb01f 408 $result = [];
6a488035
TO
409 foreach ($modules as $module) {
410 $result[$module->is_active][$module->name] = $module;
411 }
412 return $result;
413 }
414
415 /**
88718db2
TO
416 * @param array $moduleIndex
417 * @param array $declarations
77b97be7 418 *
a6c01b45
CW
419 * @return array
420 * indexed by module,name
6a488035 421 */
85917c3b 422 protected function createDeclarationIndex($moduleIndex, $declarations) {
be2fb01f 423 $result = [];
6a488035
TO
424 if (!isset($moduleIndex[TRUE])) {
425 return $result;
426 }
427 foreach ($moduleIndex[TRUE] as $moduleName => $module) {
428 if ($module->is_active) {
429 // need an empty array() for all active modules, even if there are no current $declarations
be2fb01f 430 $result[$moduleName] = [];
6a488035
TO
431 }
432 }
433 foreach ($declarations as $declaration) {
434 $result[$declaration['module']][$declaration['name']] = $declaration;
435 }
436 return $result;
437 }
438
439 /**
fd31fa4c
EM
440 * @param $declarations
441 *
72b3a70c
CW
442 * @return string|bool
443 * string on error, or FALSE
6a488035 444 */
85917c3b 445 protected function validate($declarations) {
6a488035 446 foreach ($declarations as $declare) {
be2fb01f 447 foreach (['name', 'module', 'entity', 'params'] as $key) {
6a488035
TO
448 if (empty($declare[$key])) {
449 $str = print_r($declare, TRUE);
450 return ("Managed Entity is missing field \"$key\": $str");
451 }
452 }
453 // FIXME: validate that each 'module' is known
454 }
455 return FALSE;
456 }
457
a0ee3941 458 /**
72b3a70c 459 * @param array $declarations
a0ee3941 460 *
72b3a70c 461 * @return array
a0ee3941 462 */
85917c3b 463 protected function cleanDeclarations(array $declarations): array {
6a488035
TO
464 foreach ($declarations as $name => &$declare) {
465 if (!array_key_exists('name', $declare)) {
466 $declare['name'] = $name;
467 }
468 }
469 return $declarations;
470 }
471
a0ee3941 472 /**
e9b95545
TO
473 * @param string $entity
474 * @param string $action
475 * @param array $params
476 * @param array $result
a0ee3941
EM
477 *
478 * @throws Exception
479 */
e9b95545 480 protected function onApiError($entity, $action, $params, $result) {
be2fb01f 481 CRM_Core_Error::debug_var('ManagedEntities_failed', [
e9b95545
TO
482 'entity' => $entity,
483 'action' => $action,
6a488035
TO
484 'params' => $params,
485 'result' => $result,
be2fb01f 486 ]);
35c1c211 487 throw new Exception('API error: ' . $result['error_message'] . ' on ' . $entity . '.' . $action
9f4f065a 488 . (!empty($params['name']) ? '( entity name ' . $params['name'] . ')' : '')
35c1c211 489 );
cbb7c7e0 490 }
96025800 491
fc625166
TO
492 /**
493 * Determine if an entity supports APIv3-based activation/de-activation.
494 * @param string $entity_type
495 *
496 * @return bool
497 * @throws \CiviCRM_API3_Exception
498 */
499 private function isActivationSupported(string $entity_type): bool {
500 if (!isset(Civi::$statics[__CLASS__][__FUNCTION__][$entity_type])) {
501 $actions = civicrm_api3($entity_type, 'getactions', [])['values'];
502 Civi::$statics[__CLASS__][__FUNCTION__][$entity_type] = FALSE;
503 if (in_array('create', $actions, TRUE) && in_array('getfields', $actions)) {
504 $fields = civicrm_api3($entity_type, 'getfields', ['action' => 'create'])['values'];
505 Civi::$statics[__CLASS__][__FUNCTION__][$entity_type] = array_key_exists('is_active', $fields);
506 }
507 }
508 return Civi::$statics[__CLASS__][__FUNCTION__][$entity_type];
509 }
510
6a488035 511}