Merge pull request #5998 from seamuslee001/CRM-16600-4.6
[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 array(
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 array(),
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 = array(
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 *
116 * @throws Exception
117 */
118 public function reconcile() {
119 if ($error = $this->validate($this->getDeclarations())) {
120 throw new Exception($error);
121 }
122 $this->reconcileEnabledModules();
123 $this->reconcileDisabledModules();
124 $this->reconcileUnknownModules();
125 }
126
127 /**
128 * For all enabled modules, add new entities, update
129 * existing entities, and remove orphaned (stale) entities.
130 *
131 * @throws Exception
132 */
133 public function reconcileEnabledModules() {
134 // Note: any thing currently declared is necessarily from
135 // an active module -- because we got it from a hook!
136
137 // index by moduleName,name
138 $decls = self::createDeclarationIndex($this->moduleIndex, $this->getDeclarations());
139 foreach ($decls as $moduleName => $todos) {
140 if (isset($this->moduleIndex[TRUE][$moduleName])) {
141 $this->reconcileEnabledModule($this->moduleIndex[TRUE][$moduleName], $todos);
142 }
143 elseif (isset($this->moduleIndex[FALSE][$moduleName])) {
144 // do nothing -- module should get swept up later
145 }
146 else {
147 throw new Exception("Entity declaration references invalid or inactive module name [$moduleName]");
148 }
149 }
150 }
151
152 /**
153 * For one enabled module, add new entities, update existing entities,
154 * and remove orphaned (stale) entities.
155 *
156 * @param \CRM_Core_Module $module
157 * @param array $todos
158 * List of entities currently declared by this module.
159 * array(string $name => array $entityDef).
160 */
161 public function reconcileEnabledModule(CRM_Core_Module $module, $todos) {
162 $dao = new CRM_Core_DAO_Managed();
163 $dao->module = $module->name;
164 $dao->find();
165 while ($dao->fetch()) {
166 if (isset($todos[$dao->name]) && $todos[$dao->name]) {
167 // update existing entity; remove from $todos
168 $this->updateExistingEntity($dao, $todos[$dao->name]);
169 unset($todos[$dao->name]);
170 }
171 else {
172 // remove stale entity; not in $todos
173 $this->removeStaleEntity($dao);
174 }
175 }
176
177 // create new entities from leftover $todos
178 foreach ($todos as $name => $todo) {
179 $this->insertNewEntity($todo);
180 }
181 }
182
183 /**
184 * For all disabled modules, disable any managed entities.
185 */
186 public function reconcileDisabledModules() {
187 if (empty($this->moduleIndex[FALSE])) {
188 return;
189 }
190
191 $in = CRM_Core_DAO::escapeStrings(array_keys($this->moduleIndex[FALSE]));
192 $dao = new CRM_Core_DAO_Managed();
193 $dao->whereAdd("module in ($in)");
194 $dao->find();
195 while ($dao->fetch()) {
196 $this->disableEntity($dao);
197
198 }
199 }
200
201 /**
202 * Remove any orphaned (stale) entities that are linked to
203 * unknown modules.
204 */
205 public function reconcileUnknownModules() {
206 $knownModules = array();
207 if (array_key_exists(0, $this->moduleIndex) && is_array($this->moduleIndex[0])) {
208 $knownModules = array_merge($knownModules, array_keys($this->moduleIndex[0]));
209 }
210 if (array_key_exists(1, $this->moduleIndex) && is_array($this->moduleIndex[1])) {
211 $knownModules = array_merge($knownModules, array_keys($this->moduleIndex[1]));
212 }
213
214 $dao = new CRM_Core_DAO_Managed();
215 if (!empty($knownModules)) {
216 $in = CRM_Core_DAO::escapeStrings($knownModules);
217 $dao->whereAdd("module NOT IN ($in)");
218 }
219 $dao->find();
220 while ($dao->fetch()) {
221 $this->removeStaleEntity($dao);
222 }
223 }
224
225 /**
226 * Create a new entity.
227 *
228 * @param array $todo
229 * Entity specification (per hook_civicrm_managedEntities).
230 */
231 public function insertNewEntity($todo) {
232 $result = civicrm_api($todo['entity'], 'create', $todo['params']);
233 if ($result['is_error']) {
234 $this->onApiError($todo['entity'], 'create', $todo['params'], $result);
235 }
236
237 $dao = new CRM_Core_DAO_Managed();
238 $dao->module = $todo['module'];
239 $dao->name = $todo['name'];
240 $dao->entity_type = $todo['entity'];
241 $dao->entity_id = $result['id'];
242 $dao->cleanup = CRM_Utils_Array::value('cleanup', $todo);
243 $dao->save();
244 }
245
246 /**
247 * Update an entity which (a) is believed to exist and which (b) ought to be active.
248 *
249 * @param CRM_Core_DAO_Managed $dao
250 * @param array $todo
251 * Entity specification (per hook_civicrm_managedEntities).
252 */
253 public function updateExistingEntity($dao, $todo) {
254 $policy = CRM_Utils_Array::value('update', $todo, 'always');
255 $doUpdate = ($policy == 'always');
256
257 if ($doUpdate) {
258 $defaults = array(
259 'id' => $dao->entity_id,
260 'is_active' => 1, // FIXME: test whether is_active is valid
261 );
262 $params = array_merge($defaults, $todo['params']);
263 $result = civicrm_api($dao->entity_type, 'create', $params);
264 if ($result['is_error']) {
265 $this->onApiError($dao->entity_type, 'create', $params, $result);
266 }
267 }
268
269 if (isset($todo['cleanup'])) {
270 $dao->cleanup = $todo['cleanup'];
271 $dao->update();
272 }
273 }
274
275 /**
276 * Update an entity which (a) is believed to exist and which (b) ought to be
277 * inactive.
278 *
279 * @param CRM_Core_DAO_Managed $dao
280 */
281 public function disableEntity($dao) {
282 // FIXME: if ($dao->entity_type supports is_active) {
283 if (TRUE) {
284 // FIXME cascading for payproc types?
285 $params = array(
286 'version' => 3,
287 'id' => $dao->entity_id,
288 'is_active' => 0,
289 );
290 $result = civicrm_api($dao->entity_type, 'create', $params);
291 if ($result['is_error']) {
292 $this->onApiError($dao->entity_type, 'create', $params, $result);
293 }
294 }
295 }
296
297 /**
298 * Remove a stale entity (if policy allows).
299 *
300 * @param CRM_Core_DAO_Managed $dao
301 * @throws Exception
302 */
303 public function removeStaleEntity($dao) {
304 $policy = empty($dao->cleanup) ? 'always' : $dao->cleanup;
305 switch ($policy) {
306 case 'always':
307 $doDelete = TRUE;
308 break;
309
310 case 'never':
311 $doDelete = FALSE;
312 break;
313
314 case 'unused':
315 $getRefCount = civicrm_api3($dao->entity_type, 'getrefcount', array(
316 'debug' => 1,
317 'id' => $dao->entity_id,
318 ));
319
320 $total = 0;
321 foreach ($getRefCount['values'] as $refCount) {
322 $total += $refCount['count'];
323 }
324
325 $doDelete = ($total == 0);
326 break;
327
328 default:
329 throw new \Exception('Unrecognized cleanup policy: ' . $policy);
330 }
331
332 if ($doDelete) {
333 $params = array(
334 'version' => 3,
335 'id' => $dao->entity_id,
336 );
337 $result = civicrm_api($dao->entity_type, 'delete', $params);
338 if ($result['is_error']) {
339 $this->onApiError($dao->entity_type, 'delete', $params, $result);
340 }
341
342 CRM_Core_DAO::executeQuery('DELETE FROM civicrm_managed WHERE id = %1', array(
343 1 => array($dao->id, 'Integer'),
344 ));
345 }
346 }
347
348 /**
349 * Get declarations.
350 *
351 * @return array|null
352 */
353 public function getDeclarations() {
354 if ($this->declarations === NULL) {
355 $this->declarations = array();
356 foreach (CRM_Core_Component::getEnabledComponents() as $component) {
357 /** @var CRM_Core_Component_Info $component */
358 $this->declarations = array_merge($this->declarations, $component->getManagedEntities());
359 }
360 CRM_Utils_Hook::managed($this->declarations);
361 $this->declarations = self::cleanDeclarations($this->declarations);
362 }
363 return $this->declarations;
364 }
365
366 /**
367 * @param array $modules
368 * Array<CRM_Core_Module>.
369 *
370 * @return array
371 * indexed by is_active,name
372 */
373 protected static function createModuleIndex($modules) {
374 $result = array();
375 foreach ($modules as $module) {
376 $result[$module->is_active][$module->name] = $module;
377 }
378 return $result;
379 }
380
381 /**
382 * @param array $moduleIndex
383 * @param array $declarations
384 *
385 * @return array
386 * indexed by module,name
387 */
388 protected static function createDeclarationIndex($moduleIndex, $declarations) {
389 $result = array();
390 if (!isset($moduleIndex[TRUE])) {
391 return $result;
392 }
393 foreach ($moduleIndex[TRUE] as $moduleName => $module) {
394 if ($module->is_active) {
395 // need an empty array() for all active modules, even if there are no current $declarations
396 $result[$moduleName] = array();
397 }
398 }
399 foreach ($declarations as $declaration) {
400 $result[$declaration['module']][$declaration['name']] = $declaration;
401 }
402 return $result;
403 }
404
405 /**
406 * @param $declarations
407 *
408 * @return string|bool
409 * string on error, or FALSE
410 */
411 protected static function validate($declarations) {
412 foreach ($declarations as $declare) {
413 foreach (array('name', 'module', 'entity', 'params') as $key) {
414 if (empty($declare[$key])) {
415 $str = print_r($declare, TRUE);
416 return ("Managed Entity is missing field \"$key\": $str");
417 }
418 }
419 // FIXME: validate that each 'module' is known
420 }
421 return FALSE;
422 }
423
424 /**
425 * @param array $declarations
426 *
427 * @return array
428 */
429 protected static function cleanDeclarations($declarations) {
430 foreach ($declarations as $name => &$declare) {
431 if (!array_key_exists('name', $declare)) {
432 $declare['name'] = $name;
433 }
434 }
435 return $declarations;
436 }
437
438 /**
439 * @param string $entity
440 * @param string $action
441 * @param array $params
442 * @param array $result
443 *
444 * @throws Exception
445 */
446 protected function onApiError($entity, $action, $params, $result) {
447 CRM_Core_Error::debug_var('ManagedEntities_failed', array(
448 'entity' => $entity,
449 'action' => $action,
450 'params' => $params,
451 'result' => $result,
452 ));
453 throw new Exception('API error: ' . $result['error_message']);
454 }
455
456 }