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