Revert "0010834: defining a key-value costumfield breaks addressbook"
[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      * @throws Tinebase_Exception_Record_NotDefined
282      */
283     public function setByIndices($_name, array $_values)
284     {
285         foreach ($_values as $index => $value) {
286             if (! (isset($this->_listOfRecords[$index]) || array_key_exists($index, $this->_listOfRecords))) {
287                 throw new Tinebase_Exception_Record_NotDefined('Could not find record with index ' . $index);
288             }
289             $this->_listOfRecords[$index]->$_name = $value;
290         }
291     }
292     
293     /**
294      * Sets timezone of $this->_datetimeFields
295      * 
296      * @see Tinebase_DateTime::setTimezone()
297      * @param  string $_timezone
298      * @param  bool   $_recursive
299      * @return  void
300      * @throws Tinebase_Exception_Record_Validation
301      */
302     public function setTimezone($_timezone, $_recursive = TRUE)
303     {
304         $returnValues = array();
305         foreach ($this->_listOfRecords as $index => $record) {
306             $returnValues[$index] = $record->setTimezone($_timezone, $_recursive);
307         }
308         
309         return $returnValues;
310     }
311     
312     /**
313      * sets given property in all member records of this set
314      * 
315      * @param string $_name
316      * @param mixed $_value
317      * @return void
318      */
319     public function __set($_name, $_value)
320     {
321         foreach ($this->_listOfRecords as $record) {
322             $record->$_name = $_value;
323         }
324     }
325     
326     /**
327      * returns an array with the properties of all records in this set
328      * 
329      * @param  string $_name property
330      * @return array index => property
331      */
332     public function __get($_name)
333     {
334         $propertiesArray = array();
335         
336         foreach ($this->_listOfRecords as $index => $record) {
337             $propertiesArray[$index] = $record->$_name;
338         }
339         
340         return $propertiesArray;
341     }
342     
343     /**
344      * executes given function in all records
345      *
346      * @param string $_fname
347      * @param array $_arguments
348      * @return array array index => return value
349      */
350     public function __call($_fname, $_arguments)
351     {
352         $returnValues = array();
353         foreach ($this->_listOfRecords as $index => $record) {
354             $returnValues[$index] = call_user_func_array(array($record, $_fname), $_arguments);
355         }
356         
357         return $returnValues;
358     }
359     
360    /** convert this to string
361     *
362     * @return string
363     */
364     public function __toString()
365     {
366        return print_r($this->toArray(), TRUE);
367     }
368     
369     /**
370      * Returns the number of elements in the recordSet.
371      * required by interface Countable
372      *
373      * @return int
374      */
375     public function count()
376     {
377         return count($this->_listOfRecords);
378     }
379
380     /**
381      * required by IteratorAggregate interface
382      * 
383      * @return iterator
384      */
385     public function getIterator()
386     {
387         return new ArrayIterator($this->_listOfRecords);
388     }
389
390     /**
391      * required by ArrayAccess interface
392      */
393     public function offsetExists($_offset)
394     {
395         return isset($this->_listOfRecords[$_offset]);
396     }
397     
398     /**
399      * required by ArrayAccess interface
400      */
401     public function offsetGet($_offset)
402     {
403         if (! is_int($_offset)) {
404             throw new Tinebase_Exception_UnexpectedValue("index must be of type integer (". gettype($_offset) .") " . $_offset .  ' given');
405         }
406         if (! (isset($this->_listOfRecords[$_offset]) || array_key_exists($_offset, $this->_listOfRecords))) {
407             throw new Tinebase_Exception_NotFound("No such entry with index $_offset in this record set");
408         }
409         
410         return $this->_listOfRecords[$_offset];
411     }
412     
413     /**
414      * required by ArrayAccess interface
415      */
416     public function offsetSet($_offset, $_value)
417     {
418         if (! $_value instanceof $this->_recordClass) {
419             throw new Tinebase_Exception_Record_NotAllowed('Attempt to add/set record of wrong record class. Should be ' . $this->_recordClass);
420         }
421         
422         if (!is_int($_offset)) {
423             $this->addRecord($_value);
424         } else {
425             if (!(isset($this->_listOfRecords[$_offset]) || array_key_exists($_offset, $this->_listOfRecords))) {
426                 throw new Tinebase_Exception_Record_NotAllowed('adding a record is only allowd via the addRecord method');
427             }
428             $this->_listOfRecords[$_offset] = $_value;
429             $id = $_value->getId();
430             if ($id) {
431                 if(! (isset($this->_idMap[$id]) || array_key_exists($id, $this->_idMap))) {
432                     $this->_idMap[$id] = $_offset;
433                     $idLessIdx = array_search($_offset, $this->_idLess);
434                     unset($this->_idLess[$idLessIdx]);
435                 }
436             } else {
437                 if (array_search($_offset, $this->_idLess) === false) {
438                     $this->_idLess[] = $_offset;
439                     $idMapIdx = array_search($_offset, $this->_idMap);
440                     unset($this->_idMap[$idMapIdx]);
441                 }
442             }
443         }
444     }
445     
446     /**
447      * required by ArrayAccess interface
448      */
449     public function offsetUnset($_offset)
450     {
451         $id = $this->_listOfRecords[$_offset]->getId();
452         if ($id) {
453             unset($this->_idMap[$id]);
454         } else {
455             $idLessIdx = array_search($_offset, $this->_idLess);
456             unset($this->_idLess[$idLessIdx]);
457         }
458         
459         unset($this->_listOfRecords[$_offset]);
460     }
461     
462     /**
463      * Returns an array with ids of records to delete, to create or to update
464      *
465      * @param array $_toCompareWithRecordsIds Array to compare this record sets ids with
466      * @return array An array with sub array indices 'toDeleteIds', 'toCreateIds' and 'toUpdateIds'
467      * 
468      * @deprecated please use diff() as this returns wrong result when idless records have been added
469      * @see 0007492: replace getMigration() with diff() when comparing Tinebase_Record_RecordSets
470      */
471     public function getMigration(array $_toCompareWithRecordsIds)
472     {
473         $existingRecordsIds = $this->getArrayOfIds();
474         
475         $result = array();
476         
477         $result['toDeleteIds'] = array_diff($existingRecordsIds, $_toCompareWithRecordsIds);
478         $result['toCreateIds'] = array_diff($_toCompareWithRecordsIds, $existingRecordsIds);
479         $result['toUpdateIds'] = array_intersect($existingRecordsIds, $_toCompareWithRecordsIds);
480         
481         return $result;
482     }
483
484     /**
485      * adds indices to this record set
486      *
487      * @param array $_properties
488      * @return $this
489      */
490     public function addIndices(array $_properties)
491     {
492         return $this;
493     }
494     
495     /**
496      * filter recordset and return subset
497      *
498      * @param string $_field
499      * @param string $_value
500      * @return Tinebase_Record_RecordSet
501      */
502     public function filter($_field, $_value = NULL, $_valueIsRegExp = FALSE)
503     {
504         $matchingRecords = $this->_getMatchingRecords($_field, $_value, $_valueIsRegExp);
505         
506         $result = new Tinebase_Record_RecordSet($this->_recordClass, $matchingRecords);
507         
508         return $result;
509     }
510
511     /**
512      * returns new set with records of this set
513      *
514      * @param  bool $recordsByRef
515      * @return Tinebase_Record_RecordSet
516      */
517     public function getClone($recordsByRef=false)
518     {
519         if ($recordsByRef) {
520             $result = new Tinebase_Record_RecordSet($this->_recordClass, $this->_listOfRecords);
521         } else {
522             $result = clone $this;
523         }
524
525         return $result;
526     }
527
528     /**
529      * Finds the first matching record in this store by a specific property/value.
530      *
531      * @param string $_field
532      * @param string $_value
533      * @return Tinebase_Record_Abstract
534      */
535     public function find($_field, $_value, $_valueIsRegExp = FALSE)
536     {
537         $matchingRecords = array_values($this->_getMatchingRecords($_field, $_value, $_valueIsRegExp));
538         return count($matchingRecords) > 0 ? $matchingRecords[0] : NULL;
539     }
540     
541     /**
542      * filter recordset and return matching records
543      *
544      * @param string|function $_field
545      * @param string $_value
546      * @param boolean $_valueIsRegExp
547      * @return array
548      */
549     protected function _getMatchingRecords($_field, $_value, $_valueIsRegExp = FALSE)
550     {
551         if (!is_string($_field) && is_callable($_field)) {
552             $matchingRecords = array_filter($this->_listOfRecords, $_field);
553         } else {
554             if (Tinebase_Core::isLogLevel(Zend_Log::TRACE)) Tinebase_Core::getLogger()->trace(__METHOD__ . '::' . __LINE__ . " Filtering field '$_field' of '{$this->_recordClass}' without indices, expecting slow results");
555             $valueMap = $this->$_field;
556             
557             if ($_valueIsRegExp) {
558                 $matchingMap = preg_grep($_value,  $valueMap);
559             } else {
560                 $matchingMap = array_flip((array)array_keys($valueMap, $_value));
561             }
562             
563             $matchingRecords = array_intersect_key($this->_listOfRecords, $matchingMap);
564         }
565         
566         return $matchingRecords;
567     }
568     
569     /**
570      * returns first record of this set
571      *
572      * @return Tinebase_Record_Abstract|NULL
573      */
574     public function getFirstRecord()
575     {
576         if (count($this->_listOfRecords) > 0) {
577             foreach ($this->_listOfRecords as $idx => $record) {
578                 return $record;
579             }
580         } else {
581             return NULL;
582         }
583     }
584     
585     /**
586      * compares two recordsets / only compares the ids / returns all records that are different in an array:
587      *  - removed  -> all records that are in $this but not in $_recordSet
588      *  - added    -> all records that are in $_recordSet but not in $this
589      *  - modified -> array of diffs  for all different records that are in both record sets
590      * 
591      * @param Tinebase_Record_RecordSet $recordSet
592      * @return Tinebase_Record_RecordSetDiff
593      */
594     public function diff($recordSet)
595     {
596         if (! $recordSet instanceof Tinebase_Record_RecordSet) {
597             if (Tinebase_Core::isLogLevel(Zend_Log::DEBUG)) Tinebase_Core::getLogger()->debug(__METHOD__ . '::' . __LINE__
598                 . ' Did not get Tinebase_Record_RecordSet, skipping diff(' . $this->_recordClass . ')');
599             return new Tinebase_Record_RecordSetDiff(array(
600                 'model'    => $this->getRecordClassName()
601             ));
602         }
603         
604         if ($this->getRecordClassName() !== $recordSet->getRecordClassName()) {
605             throw new Tinebase_Exception_InvalidArgument('can only compare recordsets with the same type of records');
606         }
607         
608         $existingRecordsIds = $this->getArrayOfIds();
609         $toCompareWithRecordsIds = $recordSet->getArrayOfIds();
610         
611         $removedIds = array_diff($existingRecordsIds, $toCompareWithRecordsIds);
612         $addedIds = array_diff($toCompareWithRecordsIds, $existingRecordsIds);
613         $modifiedIds = array_intersect($existingRecordsIds, $toCompareWithRecordsIds);
614         
615         $removed = new Tinebase_Record_RecordSet($this->getRecordClassName());
616         $added = new Tinebase_Record_RecordSet($this->getRecordClassName());
617         $modified = new Tinebase_Record_RecordSet('Tinebase_Record_Diff');
618         
619         foreach ($addedIds as $id) {
620             $added->addRecord($recordSet->getById($id));
621         }
622         // consider records without id, too
623         foreach ($recordSet->getIdLessIndexes() as $index) {
624             $added->addRecord($recordSet->getByIndex($index));
625         }
626         foreach ($removedIds as $id) {
627             $removed->addRecord($this->getById($id));
628         }
629         // consider records without id, too
630         foreach ($this->getIdLessIndexes() as $index) {
631             $removed->addRecord($this->getByIndex($index));
632         }
633         foreach ($modifiedIds as $id) {
634             $diff = $this->getById($id)->diff($recordSet->getById($id));
635             if (! $diff->isEmpty()) {
636                 $modified->addRecord($diff);
637             }
638         }
639         
640         $result = new Tinebase_Record_RecordSetDiff(array(
641             'model'    => $this->getRecordClassName(),
642             'added'    => $added,
643             'removed'  => $removed,
644             'modified' => $modified,
645         ));
646         
647         return $result;
648     }
649     
650     /**
651      * merges records from given record set
652      * 
653      * @param Tinebase_Record_RecordSet $_recordSet
654      * @return void
655      */
656     public function merge(Tinebase_Record_RecordSet $_recordSet)
657     {
658         foreach ($_recordSet as $record) {
659             if (! in_array($record, $this->_listOfRecords, true)) {
660                 $this->addRecord($record);
661             }
662         }
663         
664         return $this;
665     }
666     
667     /**
668      * sorts this recordset
669      *
670      * @param string $_field
671      * @param string $_direction
672      * @param string $_sortFunction
673      * @param int $_flags sort flags for asort/arsort
674      * @return $this
675      */
676     public function sort($_field, $_direction = 'ASC', $_sortFunction = 'asort', $_flags = SORT_REGULAR)
677     {
678         if (! is_string($_field) && is_callable($_field)) {
679             $_sortFunction = 'function';
680         } else {
681             $offsetToSortFieldMap = $this->__get($_field);
682         }
683
684         switch ($_sortFunction) {
685             case 'asort':
686                 $fn = $_direction == 'ASC' ? 'asort' : 'arsort';
687                 $fn($offsetToSortFieldMap, $_flags);
688                 break;
689             case 'natcasesort':
690                 natcasesort($offsetToSortFieldMap);
691                 if ($_direction == 'DESC') {
692                     // @todo check if this is working
693                     $offsetToSortFieldMap = array_reverse($offsetToSortFieldMap);
694                 }
695                 break;
696             case 'function':
697                 uasort ($this->_listOfRecords , $_field);
698                 $offsetToSortFieldMap = $this->_listOfRecords;
699                 break;
700             default:
701                 throw new Tinebase_Exception_InvalidArgument('Sort function unknown.');
702         }
703         
704         // tmp records
705         $oldListOfRecords = $this->_listOfRecords;
706         
707         // reset indexes and records
708         $this->_idLess        = array();
709         $this->_idMap         = array();
710         $this->_listOfRecords = array();
711         
712         foreach (array_keys($offsetToSortFieldMap) as $oldOffset) {
713             $this->addRecord($oldListOfRecords[$oldOffset]);
714         }
715         
716         return $this;
717     }
718
719     /**
720     * sorts this recordset by pagination sort info
721     *
722     * @param Tinebase_Model_Pagination $_pagination
723     * @return $this
724     */
725     public function sortByPagination($_pagination)
726     {
727         if ($_pagination !== NULL && $_pagination->sort) {
728             $sortField = is_array($_pagination->sort) ? $_pagination->sort[0] : $_pagination->sort;
729             $this->sort($sortField, ($_pagination->dir) ? $_pagination->dir : 'ASC');
730         }
731         
732         return $this;
733     }
734     
735     /**
736      * limits this recordset by pagination
737      * sorting should always be applied before to get the desired sequence
738      * @param Tinebase_Model_Pagination $_pagination
739      * @return $this
740      */
741     public function limitByPagination($_pagination)
742     {
743         if ($_pagination !== NULL && $_pagination->limit) {
744             $indices = range($_pagination->start, $_pagination->start + $_pagination->limit - 1);
745             foreach($this as $index => &$record) {
746                 if(! in_array($index, $indices)) {
747                     $this->offsetUnset($index);
748                 }
749             }
750         }
751         return $this;
752     }
753     
754     /**
755      * translate all member records of this set
756      * 
757      */
758     public function translate()
759     {
760         foreach ($this->_listOfRecords as $record) {
761             $record->translate();
762         }
763     }    
764 }