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