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