Merge pull request #20152 from colemanw/resetLocationProviderHashPrefix
[civicrm-core.git] / CRM / Core / ManagedEntities.php
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 */
8 class CRM_Core_ManagedEntities {
9
10 /**
11 * Get clean up options.
12 *
13 * @return array
14 */
15 public static function getCleanupOptions() {
16 return [
17 'always' => ts('Always'),
18 'never' => ts('Never'),
19 'unused' => ts('If Unused'),
20 ];
21 }
22
23 /**
24 * @var array
25 * Array($status => array($name => CRM_Core_Module)).
26 */
27 protected $moduleIndex;
28
29 /**
30 * @var array
31 * List of all entity declarations.
32 * @see CRM_Utils_Hook::managed()
33 */
34 protected $declarations;
35
36 /**
37 * Get an instance.
38 * @param bool $fresh
39 * @return \CRM_Core_ManagedEntities
40 */
41 public static function singleton($fresh = FALSE) {
42 static $singleton;
43 if ($fresh || !$singleton) {
44 $singleton = new CRM_Core_ManagedEntities(CRM_Core_Module::getAll(), NULL);
45 }
46 return $singleton;
47 }
48
49 /**
50 * Perform an asynchronous reconciliation when the transaction ends.
51 */
52 public static function scheduleReconciliation() {
53 CRM_Core_Transaction::addCallback(
54 CRM_Core_Transaction::PHASE_POST_COMMIT,
55 function () {
56 CRM_Core_ManagedEntities::singleton(TRUE)->reconcile();
57 },
58 [],
59 'ManagedEntities::reconcile'
60 );
61 }
62
63 /**
64 * @param array $modules
65 * CRM_Core_Module.
66 * @param array $declarations
67 * Per hook_civicrm_managed.
68 */
69 public function __construct($modules, $declarations) {
70 $this->moduleIndex = self::createModuleIndex($modules);
71
72 if ($declarations !== NULL) {
73 $this->declarations = self::cleanDeclarations($declarations);
74 }
75 else {
76 $this->declarations = NULL;
77 }
78 }
79
80 /**
81 * Read a managed entity using APIv3.
82 *
83 * @param string $moduleName
84 * The name of the module which declared entity.
85 * @param string $name
86 * The symbolic name of the entity.
87 * @return array|NULL
88 * API representation, or NULL if the entity does not exist
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)) {
95 $params = [
96 'id' => $dao->entity_id,
97 ];
98 $result = NULL;
99 try {
100 $result = civicrm_api3($dao->entity_type, 'getsingle', $params);
101 }
102 catch (Exception $e) {
103 $this->onApiError($dao->entity_type, 'getsingle', $params, $result);
104 }
105 return $result;
106 }
107 else {
108 return NULL;
109 }
110 }
111
112 /**
113 * Identify any enabled/disabled modules. Add new entities, update
114 * existing entities, and remove orphaned (stale) entities.
115 * @param bool $ignoreUpgradeMode
116 *
117 * @throws Exception
118 */
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 }
124 if ($error = $this->validate($this->getDeclarations())) {
125 throw new Exception($error);
126 }
127 $this->reconcileEnabledModules();
128 $this->reconcileDisabledModules();
129 $this->reconcileUnknownModules();
130 }
131
132 /**
133 * For all enabled modules, add new entities, update
134 * existing entities, and remove orphaned (stale) entities.
135 *
136 * @throws Exception
137 */
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
143 $decls = self::createDeclarationIndex($this->moduleIndex, $this->getDeclarations());
144 foreach ($decls as $moduleName => $todos) {
145 if (isset($this->moduleIndex[TRUE][$moduleName])) {
146 $this->reconcileEnabledModule($this->moduleIndex[TRUE][$moduleName], $todos);
147 }
148 elseif (isset($this->moduleIndex[FALSE][$moduleName])) {
149 // do nothing -- module should get swept up later
150 }
151 else {
152 throw new Exception("Entity declaration references invalid or inactive module name [$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 \CRM_Core_Module $module
162 * @param array $todos
163 * List of entities currently declared by this module.
164 * array(string $name => array $entityDef).
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()) {
171 if (isset($todos[$dao->name]) && $todos[$dao->name]) {
172 // update existing entity; remove from $todos
173 $this->updateExistingEntity($dao, $todos[$dao->name]);
174 unset($todos[$dao->name]);
175 }
176 else {
177 // remove stale entity; not in $todos
178 $this->removeStaleEntity($dao);
179 }
180 }
181
182 // create new entities from leftover $todos
183 foreach ($todos as $name => $todo) {
184 $this->insertNewEntity($todo);
185 }
186 }
187
188 /**
189 * For all disabled modules, disable any managed entities.
190 */
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)");
199 $dao->orderBy('id DESC');
200 $dao->find();
201 while ($dao->fetch()) {
202 $this->disableEntity($dao);
203
204 }
205 }
206
207 /**
208 * Remove any orphaned (stale) entities that are linked to
209 * unknown modules.
210 */
211 public function reconcileUnknownModules() {
212 $knownModules = [];
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]));
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)");
224 $dao->orderBy('id DESC');
225 }
226 $dao->find();
227 while ($dao->fetch()) {
228 $this->removeStaleEntity($dao);
229 }
230 }
231
232 /**
233 * Create a new entity.
234 *
235 * @param array $todo
236 * Entity specification (per hook_civicrm_managedEntities).
237 */
238 public function insertNewEntity($todo) {
239 $result = civicrm_api($todo['entity'], 'create', $todo['params']);
240 if (!empty($result['is_error'])) {
241 $this->onApiError($todo['entity'], 'create', $todo['params'], $result);
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'];
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'];
251 $dao->cleanup = $todo['cleanup'] ?? NULL;
252 $dao->save();
253 }
254
255 /**
256 * Update an entity which is believed to exist.
257 *
258 * @param CRM_Core_DAO_Managed $dao
259 * @param array $todo
260 * Entity specification (per hook_civicrm_managedEntities).
261 */
262 public function updateExistingEntity($dao, $todo) {
263 $policy = CRM_Utils_Array::value('update', $todo, 'always');
264 $doUpdate = ($policy == 'always');
265
266 if ($doUpdate) {
267 $defaults = ['id' => $dao->entity_id, 'is_active' => 1];
268 $params = array_merge($defaults, $todo['params']);
269
270 $manager = CRM_Extension_System::singleton()->getManager();
271 if ($dao->entity_type === 'Job' && !$manager->extensionIsBeingInstalledOrEnabled($dao->module)) {
272 // Special treatment for scheduled jobs:
273 //
274 // If we're being called as part of enabling/installing a module then
275 // we want the default behaviour of setting is_active = 1.
276 //
277 // However, if we're just being called by a normal cache flush then we
278 // should not re-enable a job that an administrator has decided to disable.
279 //
280 // Without this logic there was a problem: site admin might disable
281 // a job, but then when there was a flush op, the job was re-enabled
282 // which can cause significant embarrassment, depending on the job
283 // ("Don't worry, sending mailings is disabled right now...").
284 unset($params['is_active']);
285 }
286
287 $result = civicrm_api($dao->entity_type, 'create', $params);
288 if ($result['is_error']) {
289 $this->onApiError($dao->entity_type, 'create', $params, $result);
290 }
291 }
292
293 if (isset($todo['cleanup'])) {
294 $dao->cleanup = $todo['cleanup'];
295 $dao->update();
296 }
297 }
298
299 /**
300 * Update an entity which (a) is believed to exist and which (b) ought to be
301 * inactive.
302 *
303 * @param CRM_Core_DAO_Managed $dao
304 */
305 public function disableEntity($dao) {
306 // FIXME: if ($dao->entity_type supports is_active) {
307 if (TRUE) {
308 // FIXME cascading for payproc types?
309 $params = [
310 'version' => 3,
311 'id' => $dao->entity_id,
312 'is_active' => 0,
313 ];
314 $result = civicrm_api($dao->entity_type, 'create', $params);
315 if ($result['is_error']) {
316 $this->onApiError($dao->entity_type, 'create', $params, $result);
317 }
318 }
319 }
320
321 /**
322 * Remove a stale entity (if policy allows).
323 *
324 * @param CRM_Core_DAO_Managed $dao
325 * @throws Exception
326 */
327 public function removeStaleEntity($dao) {
328 $policy = empty($dao->cleanup) ? 'always' : $dao->cleanup;
329 switch ($policy) {
330 case 'always':
331 $doDelete = TRUE;
332 break;
333
334 case 'never':
335 $doDelete = FALSE;
336 break;
337
338 case 'unused':
339 $getRefCount = civicrm_api3($dao->entity_type, 'getrefcount', [
340 'debug' => 1,
341 'id' => $dao->entity_id,
342 ]);
343
344 $total = 0;
345 foreach ($getRefCount['values'] as $refCount) {
346 $total += $refCount['count'];
347 }
348
349 $doDelete = ($total == 0);
350 break;
351
352 default:
353 throw new \Exception('Unrecognized cleanup policy: ' . $policy);
354 }
355
356 if ($doDelete) {
357 $params = [
358 'version' => 3,
359 'id' => $dao->entity_id,
360 ];
361 $check = civicrm_api3($dao->entity_type, 'get', $params);
362 if ((bool) $check['count']) {
363 $result = civicrm_api($dao->entity_type, 'delete', $params);
364 if ($result['is_error']) {
365 $this->onApiError($dao->entity_type, 'delete', $params, $result);
366 }
367 }
368 CRM_Core_DAO::executeQuery('DELETE FROM civicrm_managed WHERE id = %1', [
369 1 => [$dao->id, 'Integer'],
370 ]);
371 }
372 }
373
374 /**
375 * Get declarations.
376 *
377 * @return array|null
378 */
379 public function getDeclarations() {
380 if ($this->declarations === NULL) {
381 $this->declarations = [];
382 foreach (CRM_Core_Component::getEnabledComponents() as $component) {
383 /** @var CRM_Core_Component_Info $component */
384 $this->declarations = array_merge($this->declarations, $component->getManagedEntities());
385 }
386 CRM_Utils_Hook::managed($this->declarations);
387 $this->declarations = self::cleanDeclarations($this->declarations);
388 }
389 return $this->declarations;
390 }
391
392 /**
393 * @param array $modules
394 * Array<CRM_Core_Module>.
395 *
396 * @return array
397 * indexed by is_active,name
398 */
399 protected static function createModuleIndex($modules) {
400 $result = [];
401 foreach ($modules as $module) {
402 $result[$module->is_active][$module->name] = $module;
403 }
404 return $result;
405 }
406
407 /**
408 * @param array $moduleIndex
409 * @param array $declarations
410 *
411 * @return array
412 * indexed by module,name
413 */
414 protected static function createDeclarationIndex($moduleIndex, $declarations) {
415 $result = [];
416 if (!isset($moduleIndex[TRUE])) {
417 return $result;
418 }
419 foreach ($moduleIndex[TRUE] as $moduleName => $module) {
420 if ($module->is_active) {
421 // need an empty array() for all active modules, even if there are no current $declarations
422 $result[$moduleName] = [];
423 }
424 }
425 foreach ($declarations as $declaration) {
426 $result[$declaration['module']][$declaration['name']] = $declaration;
427 }
428 return $result;
429 }
430
431 /**
432 * @param $declarations
433 *
434 * @return string|bool
435 * string on error, or FALSE
436 */
437 protected static function validate($declarations) {
438 foreach ($declarations as $declare) {
439 foreach (['name', 'module', 'entity', 'params'] as $key) {
440 if (empty($declare[$key])) {
441 $str = print_r($declare, TRUE);
442 return ("Managed Entity is missing field \"$key\": $str");
443 }
444 }
445 // FIXME: validate that each 'module' is known
446 }
447 return FALSE;
448 }
449
450 /**
451 * @param array $declarations
452 *
453 * @return array
454 */
455 protected static function cleanDeclarations($declarations) {
456 foreach ($declarations as $name => &$declare) {
457 if (!array_key_exists('name', $declare)) {
458 $declare['name'] = $name;
459 }
460 }
461 return $declarations;
462 }
463
464 /**
465 * @param string $entity
466 * @param string $action
467 * @param array $params
468 * @param array $result
469 *
470 * @throws Exception
471 */
472 protected function onApiError($entity, $action, $params, $result) {
473 CRM_Core_Error::debug_var('ManagedEntities_failed', [
474 'entity' => $entity,
475 'action' => $action,
476 'params' => $params,
477 'result' => $result,
478 ]);
479 throw new Exception('API error: ' . $result['error_message'] . ' on ' . $entity . '.' . $action);
480 }
481
482 }