6 * @subpackage Relations
7 * @license http://www.gnu.org/licenses/agpl.html AGPL Version 3
8 * @copyright Copyright (c) 2008-2013 Metaways Infosystems GmbH (http://www.metaways.de)
9 * @author Cornelius Weiss <c.weiss@metaways.de>
11 * @todo re-enable the caching (but check proper invalidation first) -> see task #232
15 * Class for handling relations between application records.
16 * @todo move json api specific stuff into the model
19 * @subpackage Relations
21 class Tinebase_Relations
24 * @var Tinebase_Relation_Backend_Sql
28 * holds the instance of the singleton
30 * @var Tinebase_Relations
32 private static $instance = NULL;
38 private function __construct()
40 $this->_backend = new Tinebase_Relation_Backend_Sql();
44 * the singleton pattern
46 * @return Tinebase_Relations
48 public static function getInstance()
50 if (self::$instance === NULL) {
51 self::$instance = new Tinebase_Relations();
53 return self::$instance;
57 * set all relations of a given record
59 * NOTE: given relation data is expected to be an array atm.
60 * @todo check read ACL for new relations to existing records.
62 * @param string $_model own model to get relations for
63 * @param string $_backend own backend to get relations for
64 * @param string $_id own id to get relations for
65 * @param array $_relationData data for relations to create
66 * @param bool $_ignoreACL create relations without checking permissions
67 * @param bool $_inspectRelated do update/create related records on the fly
68 * @param bool $_doCreateUpdateCheck do duplicate/freebusy/... checking for relations
71 public function setRelations($_model,
76 $_inspectRelated = false,
77 $_doCreateUpdateCheck = false)
79 $relations = new Tinebase_Record_RecordSet('Tinebase_Model_Relation');
80 foreach((array) $_relationData as $relationData) {
81 if ($relationData instanceof Tinebase_Model_Relation) {
82 $relations->addRecord($relationData);
84 $relation = new Tinebase_Model_Relation(NULL, TRUE);
85 $relation->setFromJsonInUsersTimezone($relationData);
86 $relations->addRecord($relation);
91 $relations->own_model = $_model;
92 $relations->own_backend = $_backend;
93 $relations->own_id = $_id;
95 // convert related_record to record objects
96 // @todo move this to a relation json class / or to model->setFromJson
97 $this->_relatedRecordToObject($relations);
99 // compute relations to add/delete
100 $currentRelations = $this->getRelations($_model, $_backend, $_id, NULL, array(), $_ignoreACL);
101 $currentIds = $currentRelations->getArrayOfIds();
102 $relationsIds = $relations->getArrayOfIds();
104 $toAdd = $relations->getIdLessIndexes();
105 $toDel = array_diff($currentIds, $relationsIds);
106 $toUpdate = array_intersect($currentIds, $relationsIds);
108 // prevent two empty related_id s of the same relation type
109 $emptyRelatedId = array();
110 foreach ($toAdd as $idx) {
111 if (empty($relations[$idx]->related_id)) {
112 $relations[$idx]->related_id = Tinebase_Record_Abstract::generateUID();
113 $emptyRelatedId[$idx] = true;
116 $this->_validateConstraintsConfig($_model, $relations, $toDel, $toUpdate);
119 foreach ($toDel as $relationId) {
120 $this->_backend->breakRelation($relationId);
124 foreach ($toAdd as $idx) {
125 if(isset($emptyRelatedId[$idx])) {
126 $relations[$idx]->related_id = null;
127 $this->_setAppRecord($relations[$idx], $_doCreateUpdateCheck);
129 $this->_addRelation($relations[$idx]);
133 foreach ($toUpdate as $relationId) {
134 $current = $currentRelations[$currentRelations->getIndexById($relationId)];
135 $update = $relations[$relations->getIndexById($relationId)];
137 // update related records if explicitly needed
138 if ($_inspectRelated) {
139 // @todo do we need to omit so many fields?
140 if (! $current->related_record->isEqual(
141 $update->related_record,
145 'last_modified_time',
155 if (Tinebase_Core::isLogLevel(Zend_Log::TRACE)) Tinebase_Core::getLogger()->trace(__METHOD__ . '::' . __LINE__
156 . ' Related record diff: ' . print_r($current->related_record->diff($update->related_record)->toArray(), true));
158 if ( !$update->related_record->has('container_id') ||
159 Tinebase_Container::getInstance()->hasGrant(Tinebase_Core::getUser()->getId(), $update->related_record->container_id,
160 array(Tinebase_Model_Grants::GRANT_EDIT, Tinebase_Model_Grants::GRANT_ADMIN)) ) {
161 $this->_setAppRecord($update, $_doCreateUpdateCheck);
163 if (Tinebase_Core::isLogLevel(Zend_Log::NOTICE)) Tinebase_Core::getLogger()->notice(__METHOD__ . '::' . __LINE__ .
164 ' Permission denied to update related record');
169 if (! $current->isEqual($update, array('related_record'))) {
170 if (Tinebase_Core::isLogLevel(Zend_Log::TRACE)) Tinebase_Core::getLogger()->trace(__METHOD__ . '::' . __LINE__
171 . ' Relation diff: ' . print_r($current->diff($update)->toArray(), true));
173 $this->_updateRelation($update);
179 * returns the constraints config for the given models and their mirrored values (seen from the other side
181 * @param array $models
184 public static function getConstraintsConfigs($models)
186 if (! is_array($models)) {
187 $models = array($models);
189 $allApplications = Tinebase_Application::getInstance()->getApplicationsByState(Tinebase_Application::ENABLED)->name;
192 foreach ($models as $model) {
194 $ownModel = explode('_Model_', $model);
196 if (! class_exists($model) || ! in_array($ownModel[0], $allApplications)) {
199 $cItems = $model::getRelatableConfig();
201 $ownApplication = $ownModel[0];
202 $ownModel = $ownModel[1];
204 if (is_array($cItems)) {
205 foreach($cItems as $cItem) {
207 if (! array_key_exists('config', $cItem)) {
212 $ownConfigItem = $cItem;
213 $ownConfigItem['ownModel'] = $ownModel;
214 $ownConfigItem['ownApp'] = $ownApplication;
215 $ownConfigItem['ownRecordClassName'] = $ownApplication . '_Model_' . $ownModel;
216 $ownConfigItem['relatedRecordClassName'] = $cItem['relatedApp'] . '_Model_' . $cItem['relatedModel'];
218 $foreignConfigItem = array(
220 'ownApp' => $cItem['relatedApp'],
221 'ownModel' => $cItem['relatedModel'],
222 'relatedModel' => $ownModel,
223 'relatedApp' => $ownApplication,
224 'default' => array_key_exists('default', $cItem) ? $cItem['default'] : NULL,
225 'ownRecordClassName' => $cItem['relatedApp'] . '_Model_' . $cItem['relatedModel'],
226 'relatedRecordClassName' => $ownApplication . '_Model_' . $ownModel
230 if (array_key_exists('keyfieldConfig', $cItem)) {
231 $foreignConfigItem['keyfieldConfig'] = $cItem['keyfieldConfig'];
232 if ($cItem['keyfieldConfig']['from']){
233 $foreignConfigItem['keyfieldConfig']['from'] = $cItem['keyfieldConfig']['from'] == 'foreign' ? 'own' : 'foreign';
238 foreach ($cItem['config'] as $conf) {
239 $max = explode(':',$conf['max']);
240 $ownConfigItem['config'][$j]['max'] = intval($max[0]);
242 $foreignConfigItem['config'][$j] = $conf;
243 $foreignConfigItem['config'][$j]['max'] = intval($max[1]);
244 if ($conf['degree'] == 'sibling') {
245 $foreignConfigItem['config'][$j]['degree'] = $conf['degree'];
247 $foreignConfigItem['config'][$j]['degree'] = $conf['degree'] == 'parent' ? 'child' : 'parent';
252 $ret[] = $ownConfigItem;
253 $ret[] = $foreignConfigItem;
262 * validate constraints from the own and the other side.
263 * this may be very expensive, if there are many constraints to check.
265 * @param string $ownModel
266 * @param Tinebase_Record_RecordSet $relations
267 * @throws Tinebase_Exception_InvalidRelationConstraints
269 protected function _validateConstraintsConfig($ownModel, $relations, $toDelete = array(), $toUpdate = array())
271 if (! $relations->count()) {
274 $relatedModels = array_unique($relations->related_model);
275 $relatedIds = array_unique($relations->related_id);
277 $toDelete = is_array($toDelete) ? $toDelete : array();
278 $toUpdate = is_array($toUpdate) ? $toUpdate : array();
279 $excludeCount = array_merge($toDelete, $toUpdate);
281 $ownId = $relations->getFirstRecord()->own_id;
283 // find out all models having a constraints config
284 $allModels = $relatedModels;
285 $allModels[] = $ownModel;
286 $allModels = array_unique($allModels);
288 $constraintsConfigs = self::getConstraintsConfigs($allModels);
289 $relatedConstraints = $this->_backend->countRelatedConstraints($ownModel, $relations, $excludeCount);
292 foreach($relations as $relation) {
293 $groups[] = $relation->related_model . '--' . $relation->type . '--' . $relation->own_id;
296 $myConstraints = array_count_values($groups);
299 foreach($relations as $relation) {
300 if (! in_array($relation->getId(), $excludeCount)) {
301 $groups[] = $relation->own_model . '--' . $relation->type . '--' . $relation->related_id;
305 foreach($relatedConstraints as $relC) {
306 for ($i = 0; $i < $relC['count']; $i++) {
307 $groups[] = $relC['id'];
311 $allConstraints = array_count_values($groups);
313 foreach ($constraintsConfigs as $cc) {
314 if (! isset($cc['config'])) {
317 foreach($cc['config'] as $config) {
319 $group = $cc['relatedRecordClassName'] . '--' . $config['type'];
320 $idGroup = $group . '--' . $ownId;
322 if (isset($myConstraints[$idGroup]) && ($config['max'] > 0 && $config['max'] < $myConstraints[$idGroup])) {
324 if (Tinebase_Core::isLogLevel(Zend_Log::INFO)) {
325 Tinebase_Core::getLogger()->info(__METHOD__ . '::' . __LINE__ . ' Constraints validation failed from the own side! ' . print_r($cc, 1));
327 throw new Tinebase_Exception_InvalidRelationConstraints();
330 // TODO: if the other side gets the config reverted here, validating constrains failes here on multiple update
331 foreach($relatedIds as $relatedId) {
332 $idGroup = $group . '--' . $relatedId;
334 if (isset($allConstraints[$idGroup]) && ($config['max'] > 0 && $config['max'] < $allConstraints[$idGroup])) {
336 if (Tinebase_Core::isLogLevel(Zend_Log::INFO)) {
337 Tinebase_Core::getLogger()->info(__METHOD__ . '::' . __LINE__ . ' Constraints validation failed from the other side! ' . print_r($cc, 1));
340 throw new Tinebase_Exception_InvalidRelationConstraints();
348 * get all relations of a given record
349 * - cache result if caching is activated
351 * @param string $_model own model to get relations for
352 * @param string $_backend own backend to get relations for
353 * @param string|array $_id own id to get relations for
354 * @param string $_degree only return relations of given degree
355 * @param array $_type only return relations of given type
356 * @param bool $_ignoreACL get relations without checking permissions
357 * @param array $_relatedModels only return relations having this related models
358 * @return Tinebase_Record_RecordSet of Tinebase_Model_Relation
360 public function getRelations($_model, $_backend, $_id, $_degree = NULL, array $_type = array(), $_ignoreACL = FALSE, $_relatedModels = NULL)
362 if (Tinebase_Core::isLogLevel(Zend_Log::TRACE)) Tinebase_Core::getLogger()->trace(__METHOD__ . '::' . __LINE__ . " model: '$_model' backend: '$_backend' "
363 // . 'ids: ' . print_r((array)$_id, true)
366 $result = $this->_backend->getAllRelations($_model, $_backend, $_id, $_degree, $_type, FALSE, $_relatedModels);
367 $this->resolveAppRecords($result, $_ignoreACL);
373 * get all relations of all given records
375 * @param string $_model own model to get relations for
376 * @param string $_backend own backend to get relations for
377 * @param array $_ids own ids to get relations for
378 * @param string $_degree only return relations of given degree
379 * @param array $_type only return relations of given type
380 * @param bool $_ignoreACL get relations without checking permissions
381 * @param array $_relatedModels only return relations having this related model
382 * @return array key from $_ids => Tinebase_Record_RecordSet of Tinebase_Model_Relation
384 public function getMultipleRelations($_model, $_backend, $_ids, $_degree = NULL, array $_type = array(), $_ignoreACL = FALSE, $_relatedModels = NULL)
386 // prepare a record set for each given id
388 foreach ($_ids as $key => $id) {
389 $result[$key] = new Tinebase_Record_RecordSet('Tinebase_Model_Relation', array(), true);
392 // fetch all relations in a single set
393 $relations = $this->getRelations($_model, $_backend, $_ids, $_degree, $_type, $_ignoreACL, $_relatedModels);
395 // sort relations into corrensponding sets
396 foreach ($relations as $relation) {
397 $keys = array_keys($_ids, $relation->own_id);
398 foreach ($keys as $key) {
399 $result[$key]->addRecord($relation);
407 * converts related_records into their appropriate record objects
408 * @todo move to model->setFromJson
410 * @param Tinebase_Model_Relation|Tinebase_Record_RecordSet
413 protected function _relatedRecordToObject($_relations)
415 if(! $_relations instanceof Tinebase_Record_RecordSet) {
416 $_relations = new Tinebase_Record_RecordSet('Tinebase_Model_Relation', array($_relations));
419 foreach ($_relations as $relation) {
420 if (! is_string($relation->related_model)) {
421 throw new Tinebase_Exception_InvalidArgument('missing relation model');
424 if (empty($relation->related_record) || $relation->related_record instanceof $relation->related_model) {
428 $data = Zend_Json::encode($relation->related_record);
429 $relation->related_record = new $relation->related_model();
430 $relation->related_record->setFromJsonInUsersTimezone($data);
435 * creates/updates application records
437 * @param Tinebase_Record_RecordSet of Tinebase_Model_Relation
438 * @param bool $_doCreateUpdateCheck
439 * @throws Tinebase_Exception_UnexpectedValue
441 protected function _setAppRecord($_relation, $_doCreateUpdateCheck = false)
443 if (! $_relation->related_record instanceof Tinebase_Record_Abstract) {
444 if (Tinebase_Core::isLogLevel(Zend_Log::DEBUG)) Tinebase_Core::getLogger()->debug(__METHOD__ . '::' . __LINE__
445 . ' Relation: ' . print_r($_relation->toArray(), TRUE));
446 throw new Tinebase_Exception_UnexpectedValue('Related record is missing from relation.');
449 $appController = Tinebase_Core::getApplicationInstance($_relation->related_model);
451 if (! $_relation->related_record->getId()) {
457 if (Tinebase_Core::isLogLevel(Zend_Log::DEBUG)) Tinebase_Core::getLogger()->debug(__METHOD__ . '::' . __LINE__
458 . ' ' . ucfirst($method) . ' ' . $_relation->related_model . ' record.');
459 if (Tinebase_Core::isLogLevel(Zend_Log::TRACE)) Tinebase_Core::getLogger()->trace(__METHOD__ . '::' . __LINE__
460 . ' Relation: ' . print_r($_relation->toArray(), TRUE));
462 $record = $appController->$method($_relation->related_record, $_doCreateUpdateCheck && $this->_doCreateUpdateCheck($_relation));
463 $_relation->related_id = $record->getId();
465 switch ($_relation->related_model) {
466 case 'Addressbook_Model_Contact':
467 $_relation->related_backend = ucfirst(Addressbook_Backend_Factory::SQL);
469 case 'Tasks_Model_Task':
470 $_relation->related_backend = Tasks_Backend_Factory::SQL;
473 $_relation->related_backend = Tinebase_Model_Relation::DEFAULT_RECORD_BACKEND;
479 * get configuration for duplicate/freebusy checks from relatable config
483 * TODO relatable config should be an object with functions to get the needed information...
485 protected function _doCreateUpdateCheck($relation)
487 $relatableConfig = call_user_func($relation->own_model . '::getRelatableConfig');
488 foreach ($relatableConfig as $config) {
489 if ($relation->related_model === $config['relatedApp'] . '_Model_' . $config['relatedModel']
490 && isset($config['createUpdateCheck'])
492 return $config['createUpdateCheck'];
499 * resolved app records and fills the related_record property with the corresponding record
501 * NOTE: With this, READ ACL is implicitly checked as non readable records won't get retuned!
503 * @param Tinebase_Record_RecordSet $_relations of Tinebase_Model_Relation
504 * @param boolean $_ignoreACL
507 * @todo make getApplicationInstance work for tinebase record (Tinebase_Model_User for example)
509 protected function resolveAppRecords($_relations, $_ignoreACL = FALSE)
511 // separate relations by model
513 foreach ($_relations as $relation) {
514 if (!(isset($modelMap[$relation->related_model]) || array_key_exists($relation->related_model, $modelMap))) {
515 $modelMap[$relation->related_model] = new Tinebase_Record_RecordSet('Tinebase_Model_Relation');
517 $modelMap[$relation->related_model]->addRecord($relation);
520 // fill related_record
521 foreach ($modelMap as $modelName => $relations) {
524 $split = explode('_Model_', $modelName);
525 $rightClass = $split[0] . '_Acl_Rights';
526 $rightName = 'manage_' . strtolower($split[1]) . 's';
528 if (class_exists($rightClass)) {
530 $ref = new ReflectionClass($rightClass);
531 $u = Tinebase_Core::getUser();
533 // if a manage right is defined and the user has no manage_record or admin right, remove relations having this record class as related model
534 if (is_object($u) && $ref->hasConstant(strtoupper($rightName)) && (! $u->hasRight($split[0], $rightName)) && (! $u->hasRight($split[0], Tinebase_Acl_Rights::ADMIN))) {
535 if (Tinebase_Core::isLogLevel(Zend_Log::DEBUG)) {
536 $_relations->removeRecords($relations);
537 Tinebase_Core::getLogger()->debug(__METHOD__ . '::' . __LINE__ . ' Skipping relation due to no manage right: ' . $modelName);
543 $getMultipleMethod = 'getMultiple';
545 if ($modelName === 'Tinebase_Model_User') {
546 // @todo add related backend here
547 //$appController = Tinebase_User::factory($relations->related_backend);
549 $appController = Tinebase_User::factory(Tinebase_User::getConfiguredBackend());
550 $records = $appController->$getMultipleMethod($relations->related_id);
553 $appController = Tinebase_Core::getApplicationInstance($modelName);
554 if (method_exists($appController, $getMultipleMethod)) {
555 $records = $appController->$getMultipleMethod($relations->related_id, $_ignoreACL);
557 // resolve record alarms
558 if (count($records) > 0 && $records->getFirstRecord()->has('alarms')) {
559 $appController->getAlarms($records);
562 throw new Tinebase_Exception_AccessDenied('Controller ' . get_class($appController) . ' has no method ' . $getMultipleMethod);
564 } catch (Tinebase_Exception_AccessDenied $tea) {
565 if (Tinebase_Core::isLogLevel(Zend_Log::INFO)) Tinebase_Core::getLogger()->info(__METHOD__ . '::' . __LINE__
566 . ' Removing relations from result. Got exception: ' . $tea->getMessage());
567 $_relations->removeRecords($relations);
572 if (Tinebase_Core::isLogLevel(Zend_Log::DEBUG)) Tinebase_Core::getLogger()->debug(__METHOD__ . '::' . __LINE__ .
573 " Resolving " . count($relations) . " relations");
575 foreach ($relations as $relation) {
576 $recordIndex = $records->getIndexById($relation->related_id);
577 $relationIndex = $_relations->getIndexById($relation->getId());
578 if ($recordIndex !== false) {
579 $_relations[$relationIndex]->related_record = $records[$recordIndex];
581 // delete relation from set, as READ ACL is obviously not granted
582 if (Tinebase_Core::isLogLevel(Zend_Log::DEBUG)) Tinebase_Core::getLogger()->debug(__METHOD__ . '::' . __LINE__ .
583 " removing $relation->related_model $relation->related_backend $relation->related_id (ACL)");
584 unset($_relations[$relationIndex]);
591 * get list of relations
593 * @param Tinebase_Model_Filter_FilterGroup|optional $_filter
594 * @param Tinebase_Model_Pagination|optional $_pagination
595 * @param boolean $_onlyIds
596 * @return Tinebase_Record_RecordSet|array
598 public function search(Tinebase_Model_Filter_FilterGroup $_filter = NULL, Tinebase_Record_Interface $_pagination = NULL, $_onlyIds = FALSE)
600 return $this->_backend->search($_filter, $_pagination, $_onlyIds);
604 * adds a new relation
606 * @param Tinebase_Model_Relation $_relation
607 * @return Tinebase_Model_Relation|NULL the new relation
608 * @throws Tinebase_Exception_Record_Validation
610 protected function _addRelation(Tinebase_Model_Relation $_relation)
612 $_relation->created_by = Tinebase_Core::getUser()->getId();
613 $_relation->creation_time = Tinebase_DateTime::now();
614 if (!$_relation->isValid()) {
615 throw new Tinebase_Exception_Record_Validation('Relation is not valid' . print_r($_relation->getValidationErrors(),true));
619 $result = $this->_backend->addRelation($_relation);
620 } catch(Zend_Db_Statement_Exception $zse) {
621 Tinebase_Core::getLogger()->warn(__METHOD__ . '::' . __LINE__ . ' Could not add relation: ' . $zse->getMessage());
629 * update an existing relation
631 * @param Tinebase_Model_Relation $_relation
632 * @return Tinebase_Model_Relation the updated relation
634 protected function _updateRelation($_relation)
636 $_relation->last_modified_by = Tinebase_Core::getUser()->getId();
637 $_relation->last_modified_time = Tinebase_DateTime::now();
639 return $this->_backend->updateRelation($_relation);
643 * replaces all relations to or from a record with $sourceId to a record with $destinationId
645 * @param string $sourceId
646 * @param string $destinationId
647 * @param string $model
651 public function transferRelations($sourceId, $destinationId, $model)
653 if (! Tinebase_Core::getUser()->hasRight('Tinebase', Tinebase_Acl_Rights::ADMIN)) {
654 throw new Tinebase_Exception_AccessDenied('Non admins of Tinebase aren\'t allowed to perform his operation!');
657 return $this->_backend->transferRelations($sourceId, $destinationId, $model);
663 * @param string|integer|Tinebase_Record_Interface|array $_id
665 * @return int The number of affected rows.
667 public function delete($_id)
669 return $this->_backend->delete($_id);
673 * remove all relations for application
675 * @param string $applicationName
679 public function removeApplication($applicationName)
681 $this->_backend->removeApplication($applicationName);