throw exception if relation is missing related_model
[tine20] / tine20 / Tinebase / Relations.php
1 <?php
2 /**
3  * Tine 2.0
4  * 
5  * @package     Tinebase
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>
10  * 
11  * @todo        re-enable the caching (but check proper invalidation first) -> see task #232
12  */
13
14 /**
15  * Class for handling relations between application records.
16  * @todo move json api specific stuff into the model
17  * 
18  * @package     Tinebase
19  * @subpackage  Relations 
20  */
21 class Tinebase_Relations
22 {
23     /**
24      * @var Tinebase_Relation_Backend_Sql
25      */
26     protected $_backend;
27     /**
28      * holds the instance of the singleton
29      *
30      * @var Tinebase_Relations
31      */
32     private static $instance = NULL;
33     
34     /**
35      * the constructor
36      *
37      */
38     private function __construct()
39     {
40         $this->_backend = new Tinebase_Relation_Backend_Sql();
41     }
42     
43     /**
44      * the singleton pattern
45      *
46      * @return Tinebase_Relations
47      */
48     public static function getInstance() 
49     {
50         if (self::$instance === NULL) {
51             self::$instance = new Tinebase_Relations();
52         }
53         return self::$instance;
54     }
55     
56     /**
57      * set all relations of a given record
58      * 
59      * NOTE: given relation data is expected to be an array atm.
60      * @todo check read ACL for new relations to existing records.
61      * 
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      * @return void
69      */
70     public function setRelations($_model, $_backend, $_id, $_relationData, $_ignoreACL = FALSE, $_inspectRelated = FALSE)
71     {
72         $relations = new Tinebase_Record_RecordSet('Tinebase_Model_Relation');
73         foreach((array) $_relationData as $relationData) {
74             if ($relationData instanceof Tinebase_Model_Relation) {
75                 $relations->addRecord($relationData);
76             } else {
77                 $relation = new Tinebase_Model_Relation(NULL, TRUE);
78                 $relation->setFromJsonInUsersTimezone($relationData);
79                 $relations->addRecord($relation);
80             }
81         }
82         
83         // own id sanitising
84         $relations->own_model   = $_model;
85         $relations->own_backend = $_backend;
86         $relations->own_id      = $_id;
87         
88         // convert related_record to record objects
89         // @todo move this to a relation json class / or to model->setFromJson
90         $this->_relatedRecordToObject($relations);
91         
92         // compute relations to add/delete
93         $currentRelations = $this->getRelations($_model, $_backend, $_id, NULL, array(), $_ignoreACL);
94         $currentIds   = $currentRelations->getArrayOfIds();
95         $relationsIds = $relations->getArrayOfIds();
96         
97         $toAdd = $relations->getIdLessIndexes();
98         $toDel = array_diff($currentIds, $relationsIds);
99         $toUpdate = array_intersect($currentIds, $relationsIds);
100         
101         $this->_validateConstraintsConfig($_model, $relations, $toDel, $toUpdate);
102         
103         // break relations
104         foreach ($toDel as $relationId) {
105             $this->_backend->breakRelation($relationId);
106         }
107         
108         // add new relations
109         foreach ($toAdd as $idx) {
110             if(empty($relations[$idx]->related_id)) {
111                 $this->_setAppRecord($relations[$idx]);
112             }
113             $this->_addRelation($relations[$idx]);
114         }
115         
116         // update relations
117         foreach ($toUpdate as $relationId) {
118             $current = $currentRelations[$currentRelations->getIndexById($relationId)];
119             $update = $relations[$relations->getIndexById($relationId)];
120             
121             // update related records if explicitly needed
122             if ($_inspectRelated) {
123                 // @todo do we need to omit so many fields?
124                 if (! $current->related_record->isEqual(
125                     $update->related_record, 
126                     array(
127                         'jpegphoto', 
128                         'creation_time', 
129                         'last_modified_time',
130                         'created_by',
131                         'last_modified_by',
132                         'is_deleted',
133                         'deleted_by',
134                         'deleted_time',
135                         'tags',
136                         'notes',
137                     )
138                 )) {
139                     if (Tinebase_Core::isLogLevel(Zend_Log::TRACE)) Tinebase_Core::getLogger()->trace(__METHOD__ . '::' . __LINE__
140                         . ' Related record diff: ' . print_r($current->related_record->diff($update->related_record)->toArray(), true));
141                     
142                     $this->_setAppRecord($update);
143                 }
144             }
145             
146             if (! $current->isEqual($update, array('related_record'))) {
147                 if (Tinebase_Core::isLogLevel(Zend_Log::TRACE)) Tinebase_Core::getLogger()->trace(__METHOD__ . '::' . __LINE__
148                     . ' Relation diff: ' . print_r($current->diff($update)->toArray(), true));
149                 
150                 $this->_updateRelation($update);
151             }
152         }
153     }
154     
155     /**
156      * returns the constraints config for the given models and their mirrored values (seen from the other side
157      * 
158      * @param array $models
159      * @return array
160      */
161     public static function getConstraintsConfigs($models)
162     {
163         if (! is_array($models)) {
164             $models = array($models);
165         }
166         $allApplications = Tinebase_Application::getInstance()->getApplicationsByState(Tinebase_Application::ENABLED)->name;
167         $ret = array();
168         
169         foreach ($models as $model) {
170         
171             $ownModel = explode('_Model_', $model);
172         
173             if (! class_exists($model) || ! in_array($ownModel[0], $allApplications)) {
174                 continue;
175             }
176             $cItems = $model::getRelatableConfig();
177             
178             $ownApplication = $ownModel[0];
179             $ownModel = $ownModel[1];
180         
181             if (is_array($cItems)) {
182                 foreach($cItems as $cItem) {
183         
184                     if (! array_key_exists('config', $cItem)) {
185                         continue;
186                     }
187         
188                     // own side
189                     $ownConfigItem = $cItem;
190                     $ownConfigItem['ownModel'] = $ownModel;
191                     $ownConfigItem['ownApp'] = $ownApplication;
192                     $ownConfigItem['ownRecordClassName'] = $ownApplication . '_Model_' . $ownModel;
193                     $ownConfigItem['relatedRecordClassName'] = $cItem['relatedApp'] . '_Model_' . $cItem['relatedModel'];
194                     
195                     $foreignConfigItem = array(
196                         'reverted'     => true,
197                         'ownApp'       => $cItem['relatedApp'],
198                         'ownModel'     => $cItem['relatedModel'],
199                         'relatedModel' => $ownModel,
200                         'relatedApp'   => $ownApplication,
201                         'default'      => array_key_exists('default', $cItem) ? $cItem['default'] : NULL,
202                         'ownRecordClassName' => $cItem['relatedApp'] . '_Model_' . $cItem['relatedModel'],
203                         'relatedRecordClassName' => $ownApplication . '_Model_' . $ownModel
204                     );
205         
206                     // KeyfieldConfigs
207                     if (array_key_exists('keyfieldConfig', $cItem)) {
208                         $foreignConfigItem['keyfieldConfig'] = $cItem['keyfieldConfig'];
209                         if ($cItem['keyfieldConfig']['from']){
210                             $foreignConfigItem['keyfieldConfig']['from'] = $cItem['keyfieldConfig']['from'] == 'foreign' ? 'own' : 'foreign';
211                         }
212                     }
213         
214                     $j=0;
215                     foreach ($cItem['config'] as $conf) {
216                         $max = explode(':',$conf['max']);
217                         $ownConfigItem['config'][$j]['max'] = intval($max[0]);
218         
219                         $foreignConfigItem['config'][$j] = $conf;
220                         $foreignConfigItem['config'][$j]['max'] = intval($max[1]);
221                         if ($conf['degree'] == 'sibling') {
222                             $foreignConfigItem['config'][$j]['degree'] = $conf['degree'];
223                         } else {
224                             $foreignConfigItem['config'][$j]['degree'] = $conf['degree'] == 'parent' ? 'child' : 'parent';
225                         }
226                         $j++;
227                     }
228                     
229                     $ret[] = $ownConfigItem;
230                     $ret[] = $foreignConfigItem;
231                 }
232             }
233         }
234         
235         return $ret;
236     }
237     
238     /**
239      * validate constraints from the own and the other side.
240      * this may be very expensive, if there are many constraints to check.
241      * 
242      * @param string $ownModel
243      * @param Tinebase_Record_RecordSet $relations
244      * @throws Tinebase_Exception_InvalidRelationConstraints
245      */
246     protected function _validateConstraintsConfig($ownModel, $relations, $toDelete = array(), $toUpdate = array())
247     {
248         if (! $relations->count()) {
249             return;
250         }
251         $relatedModels = array_unique($relations->related_model);
252         $relatedIds    = array_unique($relations->related_id);
253         
254         $toDelete      = is_array($toDelete) ? $toDelete : array();
255         $toUpdate      = is_array($toUpdate) ? $toUpdate : array();
256         $excludeCount  = array_merge($toDelete, $toUpdate);
257
258         $ownId         = $relations->getFirstRecord()->own_id;
259         $ownConfig     = $ownModel::getRelatableConfig();
260         
261         // find out all models having a constraints config
262         $allModels = $relatedModels;
263         $allModels[] = $ownModel;
264         $allModels = array_unique($allModels);
265
266         $constraintsConfigs = self::getConstraintsConfigs($allModels);
267         $relatedConstraints = $this->_backend->countRelatedConstraints($ownModel, $relations, $excludeCount);
268         
269         $newConstraints = $myConstraints = array();
270         
271         $groups = array();
272         foreach($relations as $relation) {
273             $groups[] = $relation->related_model . '--' . $relation->type . '--' . $relation->own_id;
274         }
275         
276         $myConstraints = array_count_values($groups);
277
278         $groups = array();
279         foreach($relations as $relation) {
280             if (! in_array($relation->getId(), $excludeCount)) {
281                 $groups[] = $relation->own_model . '--' . $relation->type . '--' . $relation->related_id;
282             }
283         }
284         
285         foreach($relatedConstraints as $relC) {
286             for ($i = 0; $i < $relC['count']; $i++) {
287                 $groups[] = $relC['id'];
288             }
289         }
290         
291         $allConstraints = array_count_values($groups);
292
293         foreach($constraintsConfigs as $cc) {
294             foreach($cc['config'] as $config) {
295                 
296                 $group = $cc['relatedRecordClassName'] . '--' . $config['type'];
297                 $idGroup = $group . '--' . $ownId;
298
299                 if (isset($myConstraints[$idGroup]) && ($config['max'] > 0 && $config['max'] < $myConstraints[$idGroup])) {
300                 
301                     if (Tinebase_Core::isLogLevel(Zend_Log::INFO)) {
302                         Tinebase_Core::getLogger()->info(__METHOD__ . '::' . __LINE__ . ' Constraints validation failed from the own side! ' . print_r($cc, 1));
303                     }
304                     throw new Tinebase_Exception_InvalidRelationConstraints();
305                 }
306                 
307                 // TODO: if the other side gets the config reverted here, validating constrains failes here on multiple update 
308                 foreach($relatedIds as $relatedId) {
309                     $idGroup = $group . '--' . $relatedId;
310                     
311                     if (isset($allConstraints[$idGroup]) && ($config['max'] > 0 && $config['max'] < $allConstraints[$idGroup])) {
312                         
313                         if (Tinebase_Core::isLogLevel(Zend_Log::INFO)) {
314                             Tinebase_Core::getLogger()->info(__METHOD__ . '::' . __LINE__ . ' Constraints validation failed from the other side! ' . print_r($cc, 1));
315                         }
316
317                         throw new Tinebase_Exception_InvalidRelationConstraints();
318                     }
319                 }
320             }
321         }
322     }
323     
324     /**
325      * get all relations of a given record
326      * - cache result if caching is activated
327      * 
328      * @param  string       $_model         own model to get relations for
329      * @param  string       $_backend       own backend to get relations for
330      * @param  string|array $_id            own id to get relations for
331      * @param  string       $_degree        only return relations of given degree
332      * @param  array        $_type          only return relations of given type
333      * @param  bool         $_ignoreACL     get relations without checking permissions
334      * @param  array        $_relatedModels only return relations having this related models
335      * @return Tinebase_Record_RecordSet of Tinebase_Model_Relation
336      */
337     public function getRelations($_model, $_backend, $_id, $_degree = NULL, array $_type = array(), $_ignoreACL = FALSE, $_relatedModels = NULL)
338     {
339         if (Tinebase_Core::isLogLevel(Zend_Log::TRACE)) Tinebase_Core::getLogger()->trace(__METHOD__ . '::' . __LINE__ . "  model: '$_model' backend: '$_backend' " 
340             // . 'ids: ' . print_r((array)$_id, true)
341         );
342         
343         $result = $this->_backend->getAllRelations($_model, $_backend, $_id, $_degree, $_type, FALSE, $_relatedModels);
344         $this->resolveAppRecords($result, $_ignoreACL);
345         
346         return $result;
347     }
348     
349     /**
350      * get all relations of all given records
351      * 
352      * @param  string $_model         own model to get relations for
353      * @param  string $_backend       own backend to get relations for
354      * @param  array  $_ids           own ids to get relations for
355      * @param  string $_degree        only return relations of given degree
356      * @param  array  $_type          only return relations of given type
357      * @param  bool   $_ignoreACL     get relations without checking permissions
358      * @param  array  $_relatedModels only return relations having this related model
359      * @return array  key from $_ids => Tinebase_Record_RecordSet of Tinebase_Model_Relation
360      */
361     public function getMultipleRelations($_model, $_backend, $_ids, $_degree = NULL, array $_type = array(), $_ignoreACL = FALSE, $_relatedModels = NULL)
362     {
363         // prepare a record set for each given id
364         $result = array();
365         foreach ($_ids as $key => $id) {
366             $result[$key] = new Tinebase_Record_RecordSet('Tinebase_Model_Relation', array(),  true);
367         }
368         
369         // fetch all relations in a single set
370         $relations = $this->getRelations($_model, $_backend, $_ids, $_degree, $_type, $_ignoreACL, $_relatedModels);
371         
372         // sort relations into corrensponding sets
373         foreach ($relations as $relation) {
374             $keys = array_keys($_ids, $relation->own_id);
375             foreach ($keys as $key) {
376                 $result[$key]->addRecord($relation);
377             }
378         }
379         
380         return $result;
381     }
382     
383     /**
384      * converts related_records into their appropriate record objects
385      * @todo move to model->setFromJson
386      * 
387      * @param  Tinebase_Model_Relation|Tinebase_Record_RecordSet
388      * @return void
389      */
390     protected function _relatedRecordToObject($_relations)
391     {
392         if(! $_relations instanceof Tinebase_Record_RecordSet) {
393             $_relations = new Tinebase_Record_RecordSet('Tinebase_Model_Relation', array($_relations));
394         }
395         
396         foreach ($_relations as $relation) {
397             if (! is_string($relation->related_model)) {
398                 throw new Tinebase_Exception_InvalidArgument('missing relation model');
399             }
400
401             if (empty($relation->related_record) || $relation->related_record instanceof $relation->related_model) {
402                 continue;
403             }
404             
405             $data = Zend_Json::encode($relation->related_record);
406             $relation->related_record = new $relation->related_model();
407             $relation->related_record->setFromJsonInUsersTimezone($data);
408         }
409     }
410     
411     /**
412      * creates/updates application records
413      * 
414      * @param   Tinebase_Record_RecordSet of Tinebase_Model_Relation
415      * @throws  Tinebase_Exception_UnexpectedValue
416      */
417     protected function _setAppRecord($_relation)
418     {
419         if (! $_relation->related_record instanceof Tinebase_Record_Abstract) {
420             if (Tinebase_Core::isLogLevel(Zend_Log::DEBUG)) Tinebase_Core::getLogger()->debug(__METHOD__ . '::' . __LINE__
421                 . ' Relation: ' . print_r($_relation->toArray(), TRUE));
422             throw new Tinebase_Exception_UnexpectedValue('Related record is missing from relation.');
423         }
424         
425         $appController = Tinebase_Core::getApplicationInstance($_relation->related_model);
426         
427         if (! $_relation->related_record->getId()) {
428             $method = 'create';
429         } else {
430             $method = 'update';
431         }
432
433         if (Tinebase_Core::isLogLevel(Zend_Log::DEBUG)) Tinebase_Core::getLogger()->debug(__METHOD__ . '::' . __LINE__
434             . ' ' . ucfirst($method) . ' ' . $_relation->related_model . ' record.');
435         if (Tinebase_Core::isLogLevel(Zend_Log::TRACE)) Tinebase_Core::getLogger()->trace(__METHOD__ . '::' . __LINE__
436             . ' Relation: ' . print_r($_relation->toArray(), TRUE));
437         
438         $record = $appController->$method($_relation->related_record, FALSE);
439         $_relation->related_id = $record->getId();
440         
441         switch ($_relation->related_model) {
442             case 'Addressbook_Model_Contact':
443                 $_relation->related_backend = ucfirst(Addressbook_Backend_Factory::SQL);
444                 break;
445             case 'Tasks_Model_Task':
446                 $_relation->related_backend = Tasks_Backend_Factory::SQL;
447                 break;
448             default:
449                 $_relation->related_backend = Tinebase_Model_Relation::DEFAULT_RECORD_BACKEND;
450                 break;
451         }
452     }
453     
454     /**
455      * resolved app records and fills the related_record property with the corresponding record
456      * 
457      * NOTE: With this, READ ACL is implicitly checked as non readable records won't get retuned!
458      * 
459      * @param  Tinebase_Record_RecordSet $_relations of Tinebase_Model_Relation
460      * @param  boolean $_ignoreACL 
461      * @return void
462      * 
463      * @todo    make getApplicationInstance work for tinebase record (Tinebase_Model_User for example)
464      */
465     protected function resolveAppRecords($_relations, $_ignoreACL = FALSE)
466     {
467         // separate relations by model
468         $modelMap = array();
469         foreach ($_relations as $relation) {
470             if (!(isset($modelMap[$relation->related_model]) || array_key_exists($relation->related_model, $modelMap))) {
471                 $modelMap[$relation->related_model] = new Tinebase_Record_RecordSet('Tinebase_Model_Relation');
472             }
473             $modelMap[$relation->related_model]->addRecord($relation);
474         }
475         
476         // fill related_record
477         foreach ($modelMap as $modelName => $relations) {
478             
479             // check right
480             $split = explode('_Model_', $modelName);
481             $rightClass = $split[0] . '_Acl_Rights';
482             $rightName = 'manage_' . strtolower($split[1]) . 's';
483             
484             if (class_exists($rightClass)) {
485                 
486                 $ref = new ReflectionClass($rightClass);
487                 $u = Tinebase_Core::getUser();
488                 
489                 // 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
490                 if (is_object($u) && $ref->hasConstant(strtoupper($rightName)) && (! $u->hasRight($split[0], $rightName)) && (! $u->hasRight($split[0], Tinebase_Acl_Rights::ADMIN))) {
491                     if (Tinebase_Core::isLogLevel(Zend_Log::DEBUG)) {
492                         $_relations->removeRecords($relations);
493                         Tinebase_Core::getLogger()->debug(__METHOD__ . '::' . __LINE__ . ' Skipping relation due to no manage right: ' . $modelName);
494                     }
495                     continue;
496                 }
497             }
498             
499             $getMultipleMethod = 'getMultiple';
500             
501             if ($modelName === 'Tinebase_Model_User') {
502                 // @todo add related backend here
503                 //$appController = Tinebase_User::factory($relations->related_backend);
504
505                 $appController = Tinebase_User::factory(Tinebase_User::getConfiguredBackend());
506                 $records = $appController->$getMultipleMethod($relations->related_id);
507             } else {
508                 try {
509                     $appController = Tinebase_Core::getApplicationInstance($modelName);
510                     if (method_exists($appController, $getMultipleMethod)) {
511                         $records = $appController->$getMultipleMethod($relations->related_id, $_ignoreACL);
512                         
513                         // resolve record alarms
514                         if (count($records) > 0 && $records->getFirstRecord()->has('alarms')) {
515                             $appController->getAlarms($records);
516                         }
517                     } else {
518                         throw new Tinebase_Exception_AccessDenied('Controller ' . get_class($appController) . ' has no method ' . $getMultipleMethod);
519                     }
520                 } catch (Tinebase_Exception_AccessDenied $tea) {
521                     if (Tinebase_Core::isLogLevel(Zend_Log::INFO)) Tinebase_Core::getLogger()->info(__METHOD__ . '::' . __LINE__ 
522                         . ' Removing relations from result. Got exception: ' . $tea->getMessage());
523                     $_relations->removeRecords($relations);
524                     continue;
525                 }
526             }
527
528             if (Tinebase_Core::isLogLevel(Zend_Log::DEBUG)) Tinebase_Core::getLogger()->debug(__METHOD__ . '::' . __LINE__ .
529                 " Resolving " . count($relations) . " relations");
530
531             foreach ($relations as $relation) {
532                 $recordIndex    = $records->getIndexById($relation->related_id);
533                 $relationIndex  = $_relations->getIndexById($relation->getId());
534                 if ($recordIndex !== false) {
535                     $_relations[$relationIndex]->related_record = $records[$recordIndex];
536                 } else {
537                     // delete relation from set, as READ ACL is obviously not granted 
538                     if (Tinebase_Core::isLogLevel(Zend_Log::DEBUG)) Tinebase_Core::getLogger()->debug(__METHOD__ . '::' . __LINE__ .
539                         " removing $relation->related_model $relation->related_backend $relation->related_id (ACL)");
540                     unset($_relations[$relationIndex]);
541                 }
542             }
543         }
544     }
545     
546     /**
547      * get list of relations
548      *
549      * @param Tinebase_Model_Filter_FilterGroup|optional $_filter
550      * @param Tinebase_Model_Pagination|optional $_pagination
551      * @param boolean $_onlyIds
552      * @return Tinebase_Record_RecordSet|array
553      */
554     public function search(Tinebase_Model_Filter_FilterGroup $_filter = NULL, Tinebase_Record_Interface $_pagination = NULL, $_onlyIds = FALSE)
555     {
556         return $this->_backend->search($_filter, $_pagination, $_onlyIds);
557     }
558     
559     /**
560      * adds a new relation
561      * 
562      * @param   Tinebase_Model_Relation $_relation 
563      * @return  Tinebase_Model_Relation|NULL the new relation
564      * @throws  Tinebase_Exception_Record_Validation
565      */
566     protected function _addRelation(Tinebase_Model_Relation $_relation)
567     {
568         $_relation->created_by = Tinebase_Core::getUser()->getId();
569         $_relation->creation_time = Tinebase_DateTime::now();
570         if (!$_relation->isValid()) {
571             throw new Tinebase_Exception_Record_Validation('Relation is not valid' . print_r($_relation->getValidationErrors(),true));
572         }
573         
574         try {
575             $result = $this->_backend->addRelation($_relation);
576         } catch(Zend_Db_Statement_Exception $zse) {
577             Tinebase_Core::getLogger()->warn(__METHOD__ . '::' . __LINE__ . ' Could not add relation: ' . $zse->getMessage());
578             $result = NULL;
579         }
580         
581         return $result;
582     }
583     
584     /**
585      * update an existing relation
586      * 
587      * @param  Tinebase_Model_Relation $_relation 
588      * @return Tinebase_Model_Relation the updated relation
589      */
590     protected function _updateRelation($_relation)
591     {
592         $_relation->last_modified_by = Tinebase_Core::getUser()->getId();
593         $_relation->last_modified_time = Tinebase_DateTime::now();
594         
595         return $this->_backend->updateRelation($_relation);
596     }
597     
598     /**
599      * replaces all relations to or from a record with $sourceId to a record with $destinationId
600      * 
601      * @param string $sourceId
602      * @param string $destinationId
603      * @param string $model
604      * 
605      * @return array
606      */
607     public function transferRelations($sourceId, $destinationId, $model)
608     {
609         if (! Tinebase_Core::getUser()->hasRight('Tinebase', Tinebase_Acl_Rights::ADMIN)) {
610             throw new Tinebase_Exception_AccessDenied('Non admins of Tinebase aren\'t allowed to perform his operation!');
611         }
612         
613         return $this->_backend->transferRelations($sourceId, $destinationId, $model);
614     }
615 }