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