Commit | Line | Data |
---|---|---|
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 | */ | |
8 | class 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) { | |
70 | $this->moduleIndex = self::createModuleIndex($modules); | |
e9b95545 TO |
71 | |
72 | if ($declarations !== NULL) { | |
73 | $this->declarations = self::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 | |
e9b95545 | 143 | $decls = self::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 TO |
237 | */ |
238 | public function insertNewEntity($todo) { | |
239 | $result = civicrm_api($todo['entity'], 'create', $todo['params']); | |
240 | if ($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']; | |
248 | $dao->entity_id = $result['id']; | |
9c1bc317 | 249 | $dao->cleanup = $todo['cleanup'] ?? NULL; |
7cddb4ae TO |
250 | $dao->save(); |
251 | } | |
252 | ||
253 | /** | |
20429eb9 | 254 | * Update an entity which is believed to exist. |
7cddb4ae TO |
255 | * |
256 | * @param CRM_Core_DAO_Managed $dao | |
6a0b768e TO |
257 | * @param array $todo |
258 | * Entity specification (per hook_civicrm_managedEntities). | |
7cddb4ae TO |
259 | */ |
260 | public function updateExistingEntity($dao, $todo) { | |
0dd54586 TO |
261 | $policy = CRM_Utils_Array::value('update', $todo, 'always'); |
262 | $doUpdate = ($policy == 'always'); | |
263 | ||
264 | if ($doUpdate) { | |
20429eb9 | 265 | $defaults = ['id' => $dao->entity_id, 'is_active' => 1]; |
0dd54586 | 266 | $params = array_merge($defaults, $todo['params']); |
20429eb9 RL |
267 | |
268 | $manager = CRM_Extension_System::singleton()->getManager(); | |
269 | if ($dao->entity_type === 'Job' && !$manager->extensionIsBeingInstalledOrEnabled($dao->module)) { | |
270 | // Special treatment for scheduled jobs: | |
271 | // | |
272 | // If we're being called as part of enabling/installing a module then | |
273 | // we want the default behaviour of setting is_active = 1. | |
274 | // | |
275 | // However, if we're just being called by a normal cache flush then we | |
276 | // should not re-enable a job that an administrator has decided to disable. | |
277 | // | |
278 | // Without this logic there was a problem: site admin might disable | |
279 | // a job, but then when there was a flush op, the job was re-enabled | |
280 | // which can cause significant embarrassment, depending on the job | |
281 | // ("Don't worry, sending mailings is disabled right now..."). | |
282 | unset($params['is_active']); | |
283 | } | |
284 | ||
0dd54586 TO |
285 | $result = civicrm_api($dao->entity_type, 'create', $params); |
286 | if ($result['is_error']) { | |
353ffa53 | 287 | $this->onApiError($dao->entity_type, 'create', $params, $result); |
0dd54586 | 288 | } |
7cddb4ae | 289 | } |
1f103dc4 TO |
290 | |
291 | if (isset($todo['cleanup'])) { | |
292 | $dao->cleanup = $todo['cleanup']; | |
293 | $dao->update(); | |
294 | } | |
7cddb4ae TO |
295 | } |
296 | ||
297 | /** | |
298 | * Update an entity which (a) is believed to exist and which (b) ought to be | |
299 | * inactive. | |
300 | * | |
301 | * @param CRM_Core_DAO_Managed $dao | |
302 | */ | |
303 | public function disableEntity($dao) { | |
304 | // FIXME: if ($dao->entity_type supports is_active) { | |
305 | if (TRUE) { | |
306 | // FIXME cascading for payproc types? | |
be2fb01f | 307 | $params = [ |
7cddb4ae TO |
308 | 'version' => 3, |
309 | 'id' => $dao->entity_id, | |
310 | 'is_active' => 0, | |
be2fb01f | 311 | ]; |
7cddb4ae TO |
312 | $result = civicrm_api($dao->entity_type, 'create', $params); |
313 | if ($result['is_error']) { | |
353ffa53 | 314 | $this->onApiError($dao->entity_type, 'create', $params, $result); |
7cddb4ae TO |
315 | } |
316 | } | |
317 | } | |
318 | ||
bbf66e9c | 319 | /** |
88718db2 | 320 | * Remove a stale entity (if policy allows). |
bbf66e9c TO |
321 | * |
322 | * @param CRM_Core_DAO_Managed $dao | |
88718db2 | 323 | * @throws Exception |
bbf66e9c TO |
324 | */ |
325 | public function removeStaleEntity($dao) { | |
1f103dc4 | 326 | $policy = empty($dao->cleanup) ? 'always' : $dao->cleanup; |
378e2654 TO |
327 | switch ($policy) { |
328 | case 'always': | |
329 | $doDelete = TRUE; | |
330 | break; | |
ea100cb5 | 331 | |
378e2654 TO |
332 | case 'never': |
333 | $doDelete = FALSE; | |
334 | break; | |
ea100cb5 | 335 | |
378e2654 | 336 | case 'unused': |
be2fb01f | 337 | $getRefCount = civicrm_api3($dao->entity_type, 'getrefcount', [ |
378e2654 | 338 | 'debug' => 1, |
21dfd5f5 | 339 | 'id' => $dao->entity_id, |
be2fb01f | 340 | ]); |
378e2654 TO |
341 | |
342 | $total = 0; | |
343 | foreach ($getRefCount['values'] as $refCount) { | |
344 | $total += $refCount['count']; | |
345 | } | |
346 | ||
347 | $doDelete = ($total == 0); | |
348 | break; | |
ea100cb5 | 349 | |
378e2654 TO |
350 | default: |
351 | throw new \Exception('Unrecognized cleanup policy: ' . $policy); | |
352 | } | |
bbf66e9c | 353 | |
1f103dc4 | 354 | if ($doDelete) { |
be2fb01f | 355 | $params = [ |
1f103dc4 TO |
356 | 'version' => 3, |
357 | 'id' => $dao->entity_id, | |
be2fb01f | 358 | ]; |
a60c0bc8 SL |
359 | $check = civicrm_api3($dao->entity_type, 'get', $params); |
360 | if ((bool) $check['count']) { | |
361 | $result = civicrm_api($dao->entity_type, 'delete', $params); | |
362 | if ($result['is_error']) { | |
363 | $this->onApiError($dao->entity_type, 'delete', $params, $result); | |
364 | } | |
a60c0bc8 | 365 | } |
be2fb01f CW |
366 | CRM_Core_DAO::executeQuery('DELETE FROM civicrm_managed WHERE id = %1', [ |
367 | 1 => [$dao->id, 'Integer'], | |
368 | ]); | |
1f103dc4 | 369 | } |
6a488035 TO |
370 | } |
371 | ||
2e2605fe EM |
372 | /** |
373 | * Get declarations. | |
374 | * | |
375 | * @return array|null | |
376 | */ | |
e9b95545 TO |
377 | public function getDeclarations() { |
378 | if ($this->declarations === NULL) { | |
be2fb01f | 379 | $this->declarations = []; |
e9b95545 TO |
380 | foreach (CRM_Core_Component::getEnabledComponents() as $component) { |
381 | /** @var CRM_Core_Component_Info $component */ | |
382 | $this->declarations = array_merge($this->declarations, $component->getManagedEntities()); | |
383 | } | |
384 | CRM_Utils_Hook::managed($this->declarations); | |
385 | $this->declarations = self::cleanDeclarations($this->declarations); | |
386 | } | |
387 | return $this->declarations; | |
388 | } | |
389 | ||
6a488035 | 390 | /** |
88718db2 TO |
391 | * @param array $modules |
392 | * Array<CRM_Core_Module>. | |
77b97be7 | 393 | * |
a6c01b45 CW |
394 | * @return array |
395 | * indexed by is_active,name | |
6a488035 TO |
396 | */ |
397 | protected static function createModuleIndex($modules) { | |
be2fb01f | 398 | $result = []; |
6a488035 TO |
399 | foreach ($modules as $module) { |
400 | $result[$module->is_active][$module->name] = $module; | |
401 | } | |
402 | return $result; | |
403 | } | |
404 | ||
405 | /** | |
88718db2 TO |
406 | * @param array $moduleIndex |
407 | * @param array $declarations | |
77b97be7 | 408 | * |
a6c01b45 CW |
409 | * @return array |
410 | * indexed by module,name | |
6a488035 TO |
411 | */ |
412 | protected static function createDeclarationIndex($moduleIndex, $declarations) { | |
be2fb01f | 413 | $result = []; |
6a488035 TO |
414 | if (!isset($moduleIndex[TRUE])) { |
415 | return $result; | |
416 | } | |
417 | foreach ($moduleIndex[TRUE] as $moduleName => $module) { | |
418 | if ($module->is_active) { | |
419 | // need an empty array() for all active modules, even if there are no current $declarations | |
be2fb01f | 420 | $result[$moduleName] = []; |
6a488035 TO |
421 | } |
422 | } | |
423 | foreach ($declarations as $declaration) { | |
424 | $result[$declaration['module']][$declaration['name']] = $declaration; | |
425 | } | |
426 | return $result; | |
427 | } | |
428 | ||
429 | /** | |
fd31fa4c EM |
430 | * @param $declarations |
431 | * | |
72b3a70c CW |
432 | * @return string|bool |
433 | * string on error, or FALSE | |
6a488035 TO |
434 | */ |
435 | protected static function validate($declarations) { | |
436 | foreach ($declarations as $declare) { | |
be2fb01f | 437 | foreach (['name', 'module', 'entity', 'params'] as $key) { |
6a488035 TO |
438 | if (empty($declare[$key])) { |
439 | $str = print_r($declare, TRUE); | |
440 | return ("Managed Entity is missing field \"$key\": $str"); | |
441 | } | |
442 | } | |
443 | // FIXME: validate that each 'module' is known | |
444 | } | |
445 | return FALSE; | |
446 | } | |
447 | ||
a0ee3941 | 448 | /** |
72b3a70c | 449 | * @param array $declarations |
a0ee3941 | 450 | * |
72b3a70c | 451 | * @return array |
a0ee3941 | 452 | */ |
6a488035 TO |
453 | protected static function cleanDeclarations($declarations) { |
454 | foreach ($declarations as $name => &$declare) { | |
455 | if (!array_key_exists('name', $declare)) { | |
456 | $declare['name'] = $name; | |
457 | } | |
458 | } | |
459 | return $declarations; | |
460 | } | |
461 | ||
a0ee3941 | 462 | /** |
e9b95545 TO |
463 | * @param string $entity |
464 | * @param string $action | |
465 | * @param array $params | |
466 | * @param array $result | |
a0ee3941 EM |
467 | * |
468 | * @throws Exception | |
469 | */ | |
e9b95545 | 470 | protected function onApiError($entity, $action, $params, $result) { |
be2fb01f | 471 | CRM_Core_Error::debug_var('ManagedEntities_failed', [ |
e9b95545 TO |
472 | 'entity' => $entity, |
473 | 'action' => $action, | |
6a488035 TO |
474 | 'params' => $params, |
475 | 'result' => $result, | |
be2fb01f | 476 | ]); |
6a488035 | 477 | throw new Exception('API error: ' . $result['error_message']); |
cbb7c7e0 | 478 | } |
96025800 | 479 | |
6a488035 | 480 | } |