Merge branch 'pu/2013.03/modelconfig-hr'
[tine20] / tine20 / Tinebase / Record / RecordSet.php
1 <?php
2 /**
3  * Tine 2.0
4  * 
5  * @package     Tinebase
6  * @subpackage  Record
7  * @license     http://www.gnu.org/licenses/agpl.html AGPL Version 3
8  * @copyright   Copyright (c) 2007-2011 Metaways Infosystems GmbH (http://www.metaways.de)
9  * @author      Cornelius Weiss <c.weiss@metaways.de>
10  */
11
12 /**
13  * class to hold a list of records
14  * 
15  * records are held as a unsorted set with a autoasigned numeric index.
16  * NOTE: the index of an record is _not_ related to the record and/or its identifier!
17  * 
18  * @package     Tinebase
19  * @subpackage  Record
20  *
21  */
22 class Tinebase_Record_RecordSet implements IteratorAggregate, Countable, ArrayAccess
23 {
24     /**
25      * class name of records this instance can hold
26      * @var string
27      */
28     protected $_recordClass;
29     
30     /**
31      * Holds records
32      * @var array
33      */
34     protected $_listOfRecords = array();
35     
36     /**
37      * holds mapping id -> offset in $_listOfRecords
38      * @var array
39      */
40     protected $_idMap = array();
41     
42     /**
43      * holds offsets of idless (new) records in $_listOfRecords
44      * @var array
45      */
46     protected $_idLess = array();
47     
48     /**
49      * Holds validation errors
50      * @var array
51      */
52     protected $_validationErrors = array();
53
54     /**
55      * Holds indices
56      *
57      * @var array indicesname => indicesarray
58      */
59     protected $_indices = array();
60     
61     /**
62      * creates new Tinebase_Record_RecordSet
63      *
64      * @param string $_className the required classType
65      * @param array|Tinebase_Record_RecordSet $_records array of record objects
66      * @param bool $_bypassFilters {@see Tinebase_Record_Interface::__construct}
67      * @param bool $_convertDates {@see Tinebase_Record_Interface::__construct}
68      * @return void
69      * @throws Tinebase_Exception_InvalidArgument
70      */
71     public function __construct($_className, $_records = array(), $_bypassFilters = false, $_convertDates = true)
72     {
73         if (! class_exists($_className)) {
74             throw new Tinebase_Exception_InvalidArgument('Class ' . $_className . ' does not exist');
75         }
76         $this->_recordClass = $_className;
77         
78         foreach ($_records as $record) {
79             $toAdd = $record instanceof Tinebase_Record_Abstract ? $record : new $this->_recordClass($record, $_bypassFilters, $_convertDates);
80             $this->addRecord($toAdd);
81         }
82     }
83     
84     /**
85      * clone records
86      */
87     public function __clone()
88     {
89         foreach ($this->_listOfRecords as $key => $record) {
90             $this->_listOfRecords[$key] = clone $record;
91         }
92         $this->_buildIndices();
93     }
94     
95     /**
96      * returns name of record class this recordSet contains
97      * 
98      * @returns string
99      */
100     public function getRecordClassName()
101     {
102         return $this->_recordClass;
103     }
104     
105     /**
106      * add Tinebase_Record_Interface like object to internal list
107      *
108      * @param Tinebase_Record_Interface $_record
109      * @param integer $_index
110      * @return int index in set of inserted record
111      */
112     public function addRecord(Tinebase_Record_Interface $_record, $_index = NULL)
113     {
114         if (! $_record instanceof $this->_recordClass) {
115             throw new Tinebase_Exception_Record_NotAllowed('Attempt to add/set record of wrong record class. Should be ' . $this->_recordClass);
116         }
117         $this->_listOfRecords[] = $_record;
118         end($this->_listOfRecords);
119         $index = ($_index !== NULL) ? $_index : key($this->_listOfRecords);
120         
121         // maintain indices
122         $recordId = $_record->getId();
123         if ($recordId) {
124             $this->_idMap[$recordId] = $index;
125         } else {
126             $this->_idLess[] = $index;
127         }
128         foreach ($this->_indices as $name => &$propertyIndex) {
129             $propertyIndex[$index] = $_record->$name;
130         }
131         
132         return $index;
133     }
134     
135     /**
136      * removes all records from this set
137      */
138     public function removeAll()
139     {
140         foreach($this->_listOfRecords as $record) {
141             $this->removeRecord($record);
142         }
143     }
144     
145     /**
146      * remove record from set
147      * 
148      * @param Tinebase_Record_Interface $_record
149      */
150     public function removeRecord(Tinebase_Record_Interface $_record)
151     {
152         $idx = $this->indexOf($_record);
153         if ($idx !== false) {
154             $this->offsetUnset($idx);
155         }
156     }
157
158     /**
159      * remove records from set
160      * 
161      * @param Tinebase_Record_RecordSet $_records
162      */
163     public function removeRecords(Tinebase_Record_RecordSet $_records)
164     {
165         foreach ($_records as $record) {
166             $this->removeRecord($record);
167         }
168     }
169     
170     /**
171      * get index of given record
172      * 
173      * @param Tinebase_Record_Interface $_record
174      * @return (int) index of record of false if not found
175      */
176     public function indexOf(Tinebase_Record_Interface $_record)
177     {
178         return array_search($_record, $this->_listOfRecords);
179     }
180     
181     /**
182      * checks if each member record of this set is valid
183      * 
184      * @return bool
185      */
186     public function isValid()
187     {
188         foreach ($this->_listOfRecords as $index => $record) {
189             if (!$record->isValid()) {
190                 $this->_validationErrors[$index] = $record->getValidationErrors();
191             }
192         }
193         return !(bool)count($this->_validationErrors);
194     }
195     
196     /**
197      * returns array of array of fields with validation errors 
198      *
199      * @return array index => validationErrors
200      */
201     public function getValidationErrors()
202     {
203         return $this->_validationErrors;
204     }
205     
206     /**
207      * converts RecordSet to array
208      * NOTE: keys of the array are numeric and have _noting_ to do with internal indexes or identifiers
209      * 
210      * @return array 
211      */
212     public function toArray()
213     {
214         $resultArray = array();
215         foreach($this->_listOfRecords as $index => $record) {
216             $resultArray[$index] = $record->toArray();
217         }
218          
219         return array_values($resultArray);
220     }
221     
222     /**
223      * returns index of record identified by its id
224      * 
225      * @param  string $_id id of record
226      * @return int|bool    index of record or false if not in set
227      */
228     public function getIndexById($_id)
229     {
230         return array_key_exists($_id, $this->_idMap) ? $this->_idMap[$_id] : false;
231     }
232     
233     /**
234      * returns record identified by its id
235      * 
236      * @param  string $_id id of record
237      * @return Tinebase_Record_Abstract::|bool    record or false if not in set
238      */
239     public function getById($_id)
240     {
241         $idx = $this->getIndexById($_id);
242         
243         return $idx !== false ? $this[$idx] : false;
244     }
245
246     /**
247      * returns record identified by its id
248      * 
249      * @param  integer $index of record
250      * @return Tinebase_Record_Abstract::|bool    record or false if not in set
251      */
252     public function getByIndex($index)
253     {
254         return (isset($this->_listOfRecords[$index])) ? $this->_listOfRecords[$index] : false;
255     }
256     
257     /**
258      * returns array of ids
259      */
260     public function getArrayOfIds()
261     {
262         return array_keys($this->_idMap);
263     }
264     
265     /**
266      * returns array of ids
267      */
268     public function getArrayOfIdsAsString()
269     {
270         $ids = array_keys($this->_idMap);
271         foreach($ids as $key => $id) {
272             $ids[$key] = (string) $id;
273         }
274         return $ids;
275     }
276
277     /**
278      * returns array with idless (new) records in this set
279      * 
280      * @return array
281      */
282     public function getIdLessIndexes()
283     {
284         return array_values($this->_idLess);
285     }
286     
287     /**
288      * sets given property in all records with data from given values identified by their indices
289      *
290      * @param string $_name property name
291      * @param array  $_values index => property value
292      * @throws Tinebase_Exception_Record_NotDefined
293      */
294     public function setByIndices($_name, array $_values)
295     {
296         foreach ($_values as $index => $value) {
297             if (! array_key_exists($index, $this->_listOfRecords)) {
298                 throw new Tinebase_Exception_Record_NotDefined('Could not find record with index ' . $index);
299             }
300             $this->_listOfRecords[$index]->$_name = $value;
301         }
302     }
303     
304     /**
305      * Sets timezone of $this->_datetimeFields
306      * 
307      * @see Tinebase_DateTime::setTimezone()
308      * @param  string $_timezone
309      * @param  bool   $_recursive
310      * @return  void
311      * @throws Tinebase_Exception_Record_Validation
312      */
313     public function setTimezone($_timezone, $_recursive = TRUE)
314     {
315         $returnValues = array();
316         foreach ($this->_listOfRecords as $index => $record) {
317             $returnValues[$index] = $record->setTimezone($_timezone, $_recursive);
318         }
319         
320         return $returnValues;
321     }
322     
323     /**
324      * sets given property in all member records of this set
325      * 
326      * @param string $_name
327      * @param mixed $_value
328      * @return void
329      */
330     public function __set($_name, $_value)
331     {
332         foreach ($this->_listOfRecords as $record) {
333             $record->$_name = $_value;
334         }
335         if (array_key_exists($_name, $this->_indices)) {
336             foreach ($this->_indices[$_name] as $key => $oldvalue) {
337                 $this->_indices[$_name][$key] = $_value;
338             }
339         }
340     }
341     
342     /**
343      * returns an array with the properties of all records in this set
344      * 
345      * @param  string $_name property
346      * @return array index => property
347      * 
348      * @todo reactivate indices (@see 0007558: reactivate indices in Tinebase_Record_RecordSet)
349      */
350     public function __get($_name)
351     {
352         // NOTE: indices may lead to wrong results if a record is changed after build of indices
353         if (FALSE && array_key_exists($_name, $this->_indices)) {
354             $propertiesArray = $this->_indices[$_name];
355         } else {
356             $propertiesArray = array();
357             foreach ($this->_listOfRecords as $index => $record) {
358                 $propertiesArray[$index] = $record->$_name;
359             }
360         }
361         return $propertiesArray;
362     }
363     
364     /**
365      * executes given function in all records
366      *
367      * @param string $_fname
368      * @param array $_arguments
369      * @return array array index => return value
370      */
371     public function __call($_fname, $_arguments)
372     {
373         $returnValues = array();
374         foreach ($this->_listOfRecords as $index => $record) {
375             $returnValues[$index] = call_user_func_array(array($record, $_fname), $_arguments);
376         }
377         
378         return $returnValues;
379     }
380     
381    /** convert this to string
382     *
383     * @return string
384     */
385     public function __toString()
386     {
387        return print_r($this->toArray(), TRUE);
388     }
389     
390     /**
391      * Returns the number of elements in the recordSet.
392      * required by interface Countable
393      *
394      * @return int
395      */
396     public function count()
397     {
398         return count($this->_listOfRecords);
399     }
400
401     /**
402      * required by IteratorAggregate interface
403      * 
404      * @return iterator
405      */
406     public function getIterator()
407     {
408         return new ArrayIterator($this->_listOfRecords);
409     }
410
411     /**
412      * required by ArrayAccess interface
413      */
414     public function offsetExists($_offset)
415     {
416         return isset($this->_listOfRecords[$_offset]);
417     }
418     
419     /**
420      * required by ArrayAccess interface
421      */
422     public function offsetGet($_offset)
423     {
424         if (! is_int($_offset)) {
425             throw new Tinebase_Exception_UnexpectedValue("index must be of type integer (". gettype($_offset) .") " . $_offset .  ' given');
426         }
427         if (! array_key_exists($_offset, $this->_listOfRecords)) {
428             throw new Tinebase_Exception_NotFound("No such entry with index $_offset in this record set");
429         }
430         
431         return $this->_listOfRecords[$_offset];
432     }
433     
434     /**
435      * required by ArrayAccess interface
436      */
437     public function offsetSet($_offset, $_value)
438     {
439         if (! $_value instanceof $this->_recordClass) {
440             throw new Tinebase_Exception_Record_NotAllowed('Attempt to add/set record of wrong record class. Should be ' . $this->_recordClass);
441         }
442         
443         if (!is_int($_offset)) {
444             $this->addRecord($_value);
445         } else {
446             if (!array_key_exists($_offset, $this->_listOfRecords)) {
447                 throw new Tinebase_Exception_Record_NotAllowed('adding a record is only allowd via the addRecord method');
448             }
449             $this->_listOfRecords[$_offset] = $_value;
450             $id = $_value->getId();
451             if ($id) {
452                 if(! array_key_exists($id, $this->_idMap)) {
453                     $this->_idMap[$id] = $_offset;
454                     $idLessIdx = array_search($_offset, $this->_idLess);
455                     unset($this->_idLess[$idLessIdx]);
456                 }
457             } else {
458                 if (array_search($_offset, $this->_idLess) === false) {
459                     $this->_idLess[] = $_offset;
460                     $idMapIdx = array_search($_offset, $this->_idMap);
461                     unset($this->_idMap[$idMapIdx]);
462                 }
463             }
464         }
465     }
466     
467     /**
468      * required by ArrayAccess interface
469      */
470     public function offsetUnset($_offset)
471     {
472         $id = $this->_listOfRecords[$_offset]->getId();
473         if ($id) {
474             unset($this->_idMap[$id]);
475         } else {
476             $idLessIdx = array_search($_offset, $this->_idLess);
477             unset($this->_idLess[$idLessIdx]);
478         }
479         
480         unset($this->_listOfRecords[$_offset]);
481     }
482     
483     /**
484      * Returns an array with ids of records to delete, to create or to update
485      *
486      * @param array $_toCompareWithRecordsIds Array to compare this record sets ids with
487      * @return array An array with sub array indices 'toDeleteIds', 'toCreateIds' and 'toUpdateIds'
488      * 
489      * @deprecated please use diff() as this returns wrong result when idless records have been added
490      * @see 0007492: replace getMigration() with diff() when comparing Tinebase_Record_RecordSets
491      */
492     public function getMigration(array $_toCompareWithRecordsIds)
493     {
494         $existingRecordsIds = $this->getArrayOfIds();
495         
496         $result = array();
497         
498         $result['toDeleteIds'] = array_diff($existingRecordsIds, $_toCompareWithRecordsIds);
499         $result['toCreateIds'] = array_diff($_toCompareWithRecordsIds, $existingRecordsIds);
500         $result['toUpdateIds'] = array_intersect($existingRecordsIds, $_toCompareWithRecordsIds);
501         
502         return $result;
503     }
504
505     /**
506      * adds indices to this record set
507      *
508      * @param array $_properties
509      * @return $this
510      */
511     public function addIndices(array $_properties)
512     {
513         if (! empty($_properties)) {
514             foreach ($_properties as $property) {
515                 if (! array_key_exists($property, $this->_indices)) {
516                     $this->_indices[$property] = array();
517                 }
518             }
519             
520             $this->_buildIndices();
521         }
522         
523         return $this;
524     }
525     
526     /**
527      * build all indices of this set
528      *
529      */
530     protected function _buildIndices()
531     {
532         foreach ($this->_indices as $name => $propertyIndex) {
533             unset($this->_indices[$name]);
534             $this->_indices[$name] = $this->__get($name);
535         }
536     }
537     
538     /**
539      * filter recordset and return subset
540      *
541      * @param string $_field
542      * @param string $_value
543      * @return Tinebase_Record_RecordSet
544      */
545     public function filter($_field, $_value, $_valueIsRegExp = FALSE)
546     {
547         $matchingRecords = $this->_getMatchingRecords($_field, $_value, $_valueIsRegExp);
548         
549         $result = new Tinebase_Record_RecordSet($this->_recordClass, $matchingRecords);
550         $result->addIndices(array_keys($this->_indices));
551         
552         return $result;
553     }
554     
555     /**
556      * Finds the first matching record in this store by a specific property/value.
557      *
558      * @param string $_field
559      * @param string $_value
560      * @return Tinebase_Record_Abstract
561      */
562     public function find($_field, $_value, $_valueIsRegExp = FALSE)
563     {
564         $matchingRecords = array_values($this->_getMatchingRecords($_field, $_value, $_valueIsRegExp));
565         return count($matchingRecords) > 0 ? $matchingRecords[0] : NULL;
566     }
567     
568     /**
569      * filter recordset and return matching records
570      *
571      * @param string $_field
572      * @param string $_value
573      * @return array
574      * 
575      * @todo reactivate indices (@see 0007558: reactivate indices in Tinebase_Record_RecordSet)
576      */
577     protected function _getMatchingRecords($_field, $_value, $_valueIsRegExp = FALSE)
578     {
579         // NOTE: indices may lead to wrong results if a record is changed after build of indices
580         if (FALSE && array_key_exists($_field, $this->_indices)) {
581             if (Tinebase_Core::isLogLevel(Zend_Log::TRACE)) Tinebase_Core::getLogger()->trace(__METHOD__ . '::' . __LINE__ . ' Filtering with indices, expecting fast results ;-)');
582             $valueMap = $this->_indices[$_field];
583         } else {
584             if (Tinebase_Core::isLogLevel(Zend_Log::TRACE)) Tinebase_Core::getLogger()->trace(__METHOD__ . '::' . __LINE__ . " Filtering field '$_field' of '{$this->_recordClass}' without indices, expecting slow results");
585             $valueMap = $this->$_field;
586         }
587         
588         if ($_valueIsRegExp) {
589             $matchingMap = preg_grep($_value,  $valueMap);
590         } else {
591             $matchingMap = array_flip((array)array_keys($valueMap, $_value));
592         }
593         
594         $matchingRecords = array_intersect_key($this->_listOfRecords, $matchingMap);
595         return $matchingRecords;
596     }
597     
598     /**
599      * returns first record of this set
600      *
601      * @return Tinebase_Record_Abstract|NULL
602      */
603     public function getFirstRecord()
604     {
605         if (count($this->_listOfRecords) > 0) {
606             foreach ($this->_listOfRecords as $idx => $record) {
607                 return $record;
608             }
609         } else {
610             return NULL;
611         }
612     }
613     
614     /**
615      * compares two recordsets / only compares the ids / returns all records that are different in an array:
616      *  - removed  -> all records that are in $this but not in $_recordSet
617      *  - added    -> all records that are in $_recordSet but not in $this
618      *  - modified -> array of diffs  for all different records that are in both record sets
619      * 
620      * @param Tinebase_Record_RecordSet $recordSet
621      * @return Tinebase_Record_RecordSetDiff
622      */
623     public function diff($recordSet)
624     {
625         if (! $recordSet instanceof Tinebase_Record_RecordSet) {
626             if (Tinebase_Core::isLogLevel(Zend_Log::DEBUG)) Tinebase_Core::getLogger()->debug(__METHOD__ . '::' . __LINE__
627                 . ' Did not get Tinebase_Record_RecordSet, skipping diff(' . $this->_recordClass . ')');
628             return new Tinebase_Record_RecordSetDiff(array(
629                 'model'    => $this->getRecordClassName()
630             ));
631         }
632         
633         if ($this->getRecordClassName() !== $recordSet->getRecordClassName()) {
634             throw new Tinebase_Exception_InvalidArgument('can only compare recordsets with the same type of records');
635         }
636         
637         $existingRecordsIds = $this->getArrayOfIds();
638         $toCompareWithRecordsIds = $recordSet->getArrayOfIds();
639         
640         $removedIds = array_diff($existingRecordsIds, $toCompareWithRecordsIds);
641         $addedIds = array_diff($toCompareWithRecordsIds, $existingRecordsIds);
642         $modifiedIds = array_intersect($existingRecordsIds, $toCompareWithRecordsIds);
643         
644         $removed = new Tinebase_Record_RecordSet($this->getRecordClassName());
645         $added = new Tinebase_Record_RecordSet($this->getRecordClassName());
646         $modified = new Tinebase_Record_RecordSet('Tinebase_Record_Diff');
647         
648         foreach ($addedIds as $id) {
649             $added->addRecord($recordSet->getById($id));
650         }
651         // consider records without id, too
652         foreach ($recordSet->getIdLessIndexes() as $index) {
653             $added->addRecord($recordSet->getByIndex($index));
654         }
655         foreach ($removedIds as $id) {
656             $removed->addRecord($this->getById($id));
657         }
658         // consider records without id, too
659         foreach ($this->getIdLessIndexes() as $index) {
660             $removed->addRecord($this->getByIndex($index));
661         }
662         foreach ($modifiedIds as $id) {
663             $diff = $this->getById($id)->diff($recordSet->getById($id));
664             if (! $diff->isEmpty()) {
665                 $modified->addRecord($diff);
666             }
667         }
668         
669         $result = new Tinebase_Record_RecordSetDiff(array(
670             'model'    => $this->getRecordClassName(),
671             'added'    => $added,
672             'removed'  => $removed,
673             'modified' => $modified,
674         ));
675         
676         return $result;
677     }
678     
679     /**
680      * merges records from given record set
681      * 
682      * @param Tinebase_Record_RecordSet $_recordSet
683      * @return void
684      */
685     public function merge(Tinebase_Record_RecordSet $_recordSet)
686     {
687         foreach ($_recordSet as $record) {
688             if (! in_array($record, $this->_listOfRecords, true)) {
689                 $this->addRecord($record);
690             }
691         }
692         
693         return $this;
694     }
695     
696     /**
697      * sorts this recordset
698      *
699      * @param string $_field
700      * @param string $_direction
701      * @param string $_sortFunction
702      * @param int $_flags sort flags for asort/arsort
703      * @return $this
704      */
705     public function sort($_field, $_direction = 'ASC', $_sortFunction = 'asort', $_flags = SORT_REGULAR)
706     {
707         $offsetToSortFieldMap = $this->__get($_field);
708         
709         switch ($_sortFunction) {
710             case 'asort':
711                 $fn = $_direction == 'ASC' ? 'asort' : 'arsort';
712                 $fn($offsetToSortFieldMap, $_flags);
713                 break;
714             case 'natcasesort':
715                 natcasesort($offsetToSortFieldMap);
716                 if ($_direction == 'DESC') {
717                     // @todo check if this is working
718                     $offsetToSortFieldMap = array_reverse($offsetToSortFieldMap);
719                 }
720                 break;
721             default:
722                 throw new Tinebase_Exception_InvalidArgument('Sort function unknown.');
723         }
724         
725         // tmp records
726         $oldListOfRecords = $this->_listOfRecords;
727         
728         // reset indexes and records
729         $this->_idLess        = array();
730         $this->_idMap         = array();
731         $this->_listOfRecords = array();
732         $namedIndices = array_keys($this->_indices);
733         $this->_indices = array();
734         $this->addIndices($namedIndices);
735         
736         foreach (array_keys($offsetToSortFieldMap) as $oldOffset) {
737             $this->addRecord($oldListOfRecords[$oldOffset]);
738         }
739         
740         return $this;
741     }
742
743     /**
744     * sorts this recordset by pagination sort info
745     *
746     * @param Tinebase_Model_Pagination $_pagination
747     * @return $this
748     */
749     public function sortByPagination($_pagination)
750     {
751         if ($_pagination !== NULL && $_pagination->sort) {
752             $sortField = is_array($_pagination->sort) ? $_pagination->sort[0] : $_pagination->sort;
753             $this->sort($sortField, ($_pagination->dir) ? $_pagination->dir : 'ASC');
754         }
755         
756         return $this;
757     }
758     
759     /**
760      * limits this recordset by pagination
761      * sorting should always be applied before to get the desired sequence
762      * @param Tinebase_Model_Pagination $_pagination
763      * @return $this
764      */
765     public function limitByPagination($_pagination)
766     {
767         if ($_pagination !== NULL && $_pagination->limit) {
768             $indices = range($_pagination->start, $_pagination->start + $_pagination->limit - 1);
769             foreach($this as $index => &$record) {
770                 if(! in_array($index, $indices)) {
771                     $this->offsetUnset($index);
772                 }
773             }
774         }
775         return $this;
776     }
777     
778     /**
779      * translate all member records of this set
780      * 
781      */
782     public function translate()
783     {
784         foreach ($this->_listOfRecords as $record) {
785             $record->translate();
786         }
787     }    
788 }