Version: current

       1 <?php
       2 /**
       3  * This class represents a set of {@link ViewableData} subclasses (mostly {@link DataObject} or {@link ArrayData}).
       4  * It is used by the ORM-layer of Silverstripe to return query-results from {@link SQLQuery}.
       5  * @package sapphire
       6  * @subpackage model
       7  */
       8 class DataObjectSet extends ViewableData implements IteratorAggregate, Countable {
       9     /**
      10      * The DataObjects in this set.
      11      * @var array
      12      */
      13     protected $items = array();
      14
      15     protected $odd = 0;
      16
      17     /**
      18      * True if the current DataObject is the first in this set.
      19      * @var boolean
      20      */
      21     protected $first = true;
      22
      23     /**
      24      * True if the current DataObject is the last in this set.
      25      * @var boolean
      26      */
      27     protected $last = false;
      28
      29     /**
      30      * The current DataObject in this set.
      31      * @var DataObject
      32      */
      33     protected $current = null;
      34
      35     /**
      36      * The number object the current page starts at.
      37      * @var int
      38      */
      39     protected $pageStart;
      40
      41     /**
      42      * The number of objects per page.
      43      * @var int
      44      */
      45     protected $pageLength;
      46
      47     /**
      48      * Total number of DataObjects in this set.
      49      * @var int
      50      */
      51     protected $totalSize;
      52
      53     /**
      54      * The pagination GET variable that controls the start of this set.
      55      * @var string
      56      */
      57     protected $paginationGetVar = "start";
      58
      59     /**
      60      * Create a new DataObjectSet. If you pass one or more arguments, it will try to convert them into {@link ArrayData} objects.
      61      * @todo Does NOT automatically convert objects with complex datatypes (e.g. converting arrays within an objects to its own DataObjectSet)
      62      *
      63      * @param ViewableData|array|mixed $items Parameters to use in this set, either as an associative array, object with simple properties, or as multiple parameters.
      64      */
      65     public function __construct($items = null) {
      66         if($items) {
      67             // if the first parameter is not an array, or we have more than one parameter, collate all parameters to an array
      68             // otherwise use the passed array
      69             $itemsArr = (!is_array($items) || count(func_get_args()) > 1) ? func_get_args() : $items;
      70
      71             // We now have support for using the key of a data object set
      72             foreach($itemsArr as $i => $item) {
      73                 if(is_subclass_of($item, 'ViewableData')) {
      74                     $this->items[$i] = $item;
      75                 } elseif(is_object($item) || ArrayLib::is_associative($item)) {
      76                     $this->items[$i] = new ArrayData($item);
      77                 } else {
      78                     user_error(
      79                         "DataObjectSet::__construct: Passed item #{$i} is not an object or associative array,
      80                         can't be properly iterated on in templates",
      81                         E_USER_WARNING
      82                     );
      83                     $this->items[$i] = $item;
      84                 }
      85             }
      86
      87
      88         }
      89         parent::__construct();
      90     }
      91
      92     /**
      93      * Destory all of the DataObjects in this set.
      94      */
      95     public function destroy() {
      96         foreach($this->items as $item) {
      97             $item->destroy();
      98         }
      99     }
     100
     101     /**
     102      * Removes all the items in this set.
     103      */
     104     public function emptyItems() {
     105         $this->items = array();
     106     }
     107
     108     /**
     109      * Convert this DataObjectSet to an array of DataObjects.
     110      * @param string $index Index the array by this field.
     111      * @return array
     112      */
     113     public function toArray($index = null) {
     114         if(!$index) {
     115             return $this->items;
     116         }
     117
     118         $map = array();
     119
     120         foreach($this->items as $item) {
     121             $map[$item->$index] = $item;
     122         }
     123
     124         return $map;
     125     }
     126
     127     /**
     128     * Convert this DataObjectSet to an array of maps.
     129     * @param string $index Index the array by this field.
     130     * @return array
     131     */
     132     public function toNestedArray($index = null){
     133         if(!$index) {
     134             $index = "ID";
     135         }
     136
     137         $map = array();
     138
     139         foreach( $this->items as $item ) {
     140             $map[$item->$index] = $item->getAllFields();
     141         }
     142
     143         return $map;
     144     }
     145
     146     /**
     147      * Returns an array of ID => Title for the items in this set.
     148      *
     149      * This is an alias of {@link DataObjectSet->map()}
     150      *
     151      * @deprecated 2.5 Please use map() instead
     152      *
     153      * @param string $index The field to use as a key for the array
     154      * @param string $titleField The field (or method) to get values for the map
     155      * @param string $emptyString Empty option text e.g "(Select one)"
     156      * @param bool $sort Sort the map alphabetically based on the $titleField value
     157      * @return array
     158      */
     159     public function toDropDownMap($index = 'ID', $titleField = 'Title', $emptyString = null, $sort = false) {
     160         return $this->map($index, $titleField, $emptyString, $sort);
     161     }
     162
     163     /**
     164      * Set number of objects on each page.
     165      * @param int $length Number of objects per page
     166      */
     167     public function setPageLength($length) {
     168         $this->pageLength = $length;
     169     }
     170
     171     /**
     172      * Set the page limits.
     173      * @param int $pageStart The start of this page.
     174      * @param int $pageLength Number of objects per page
     175      * @param int $totalSize Total number of objects.
     176      */
     177     public function setPageLimits($pageStart, $pageLength, $totalSize) {
     178         $this->pageStart = $pageStart;
     179         $this->pageLength = $pageLength;
     180         $this->totalSize = $totalSize;
     181     }
     182
     183     /**
     184      * Get the page limits
     185      * @return array
     186      */
     187     public function getPageLimits() {
     188         return array(
     189             'pageStart' => $this->pageStart,
     190             'pageLength' => $this->pageLength,
     191             'totalSize' => $this->totalSize,
     192         );
     193     }
     194
     195     /**
     196      * Use the limit from the given query to add prev/next buttons to this DataObjectSet.
     197      * @param SQLQuery $query The query used to generate this DataObjectSet
     198      */
     199     public function parseQueryLimit(SQLQuery $query) {
     200         if($query->limit) {
     201             if(is_array($query->limit)) {
     202                 $length = $query->limit['limit'];
     203                 $start = $query->limit['start'];
     204             } else if(stripos($query->limit, 'OFFSET')) {
     205                 list($length, $start) = preg_split("/ +OFFSET +/i", trim($query->limit));
     206             } else {
     207                 $result = preg_split("/ *, */", trim($query->limit));
     208                 $start = $result[0];
     209                 $length = isset($result[1]) ? $result[1] : null;
     210             }
     211
     212             if(!$length) {
     213                 $length = $start;
     214                 $start = 0;
     215             }
     216             $this->setPageLimits($start, $length, $query->unlimitedRowCount());
     217         }
     218     }
     219
     220     /**
     221      * Returns the number of the current page.
     222      * @return int
     223      */
     224     public function CurrentPage() {
     225         return floor($this->pageStart / $this->pageLength) + 1;
     226     }
     227
     228     /**
     229      * Returns the total number of pages.
     230      * @return int
     231      */
     232     public function TotalPages() {
     233         if($this->totalSize == 0) {
     234             $this->totalSize = $this->Count();
     235         }
     236         if($this->pageLength == 0) {
     237             $this->pageLength = 10;
     238         }
     239
     240         return ceil($this->totalSize / $this->pageLength);
     241     }
     242
     243     /**
     244      * Return a datafeed of page-links, good for use in search results, etc.
     245      * $maxPages will put an upper limit on the number of pages to return.  It will
     246      * show the pages surrounding the current page, so you can still get to the deeper pages.
     247      * @param int $maxPages The maximum number of pages to return
     248      * @return DataObjectSet
     249      */
     250     public function Pages($maxPages = 0){
     251         $ret = new DataObjectSet();
     252
     253         if($maxPages) {
     254             $startPage = ($this->CurrentPage() - floor($maxPages / 2)) - 1;
     255             $endPage = $this->CurrentPage() + floor($maxPages / 2);
     256
     257             if($startPage < 0) {
     258                 $startPage = 0;
     259                 $endPage = $maxPages;
     260             }
     261             if($endPage > $this->TotalPages()) {
     262                 $endPage = $this->TotalPages();
     263                 $startPage = max(0, $endPage - $maxPages);
     264             }
     265
     266         } else {
     267             $startPage = 0;
     268             $endPage = $this->TotalPages();
     269         }
     270
     271         for($i=$startPage; $i < $endPage; $i++){
     272             $link = HTTP::setGetVar($this->paginationGetVar, ($i > 0) ? $i * $this->pageLength : null);
     273             $currentBool = ($this->CurrentPage() == $i+1) ? true : false;
     274             $thePage = new ArrayData(array(
     275                     "PageNum" => $i+1,
     276                     "Link" => $link,
     277                     "CurrentBool" => $currentBool,
     278                     "TotalPages" => $this->TotalPages(),
     279                     'StartItem' => $i * $this->pageLength + 1,
     280                     'EndItem' => ($i + 1) * $this->pageLength,
     281                     'LinkOrCurrent' => ($currentBool) ? 'current' : 'link',
     282             ));
     283             $ret->push($thePage);
     284         }
     285
     286         return $ret;
     287     }
     288
     289     /*
     290      * Display a summarized pagination which limits the number of pages shown
     291      * "around" the currently active page for visual balance.
     292      * In case more paginated pages have to be displayed, only
     293      *
     294      * Example: 25 pages total, currently on page 6, context of 4 pages
     295      * [prev] [1] ... [4] [5] [[6]] [7] [8] ... [25] [next]
     296      *
     297      * Example template usage:
     298      * <code>
     299      * <% if MyPages.MoreThanOnePage %>
     300      *  <% if MyPages.NotFirstPage %>
     301      *      <a class="prev" href="$MyPages.PrevLink">Prev</a>
     302      *  <% end_if %>
     303      *  <% control MyPages.PaginationSummary(4) %>
     304      *      <% if CurrentBool %>
     305      *          $PageNum
     306      *      <% else %>
     307      *          <% if Link %>
     308      *              <a href="$Link">$PageNum</a>
     309      *          <% else %>
     310      *              ...
     311      *          <% end_if %>
     312      *      <% end_if %>
     313      *  <% end_control %>
     314      *  <% if MyPages.NotLastPage %>
     315      *      <a class="next" href="$MyPages.NextLink">Next</a>
     316      *  <% end_if %>
     317      * <% end_if %>
     318      * </code>
     319      *
     320      * @param integer $context Number of pages to display "around" the current page. Number should be even,
     321      *  because its halved to either side of the current page.
     322      * @return  DataObjectSet
     323      */
     324     public function PaginationSummary($context = 4) {
     325         $ret = new DataObjectSet();
     326
     327         // convert number of pages to even number for offset calculation
     328         if($context % 2) $context--;
     329
     330         // find out the offset
     331         $current = $this->CurrentPage();
     332         $totalPages = $this->TotalPages();
     333
     334         // if the first or last page is shown, use all content on one side (either left or right of current page)
     335         // otherwise half the number for usage "around" the current page
     336         $offset = ($current == 1 || $current == $totalPages) ? $context : floor($context/2);
     337
     338         $leftOffset = $current - ($offset);
     339         if($leftOffset < 1) $leftOffset = 1;
     340         if($leftOffset + $context > $totalPages) $leftOffset = $totalPages - $context;
     341
     342         for($i=0; $i < $totalPages; $i++) {
     343             $link = HTTP::setGetVar($this->paginationGetVar, $i*$this->pageLength);
     344             $num = $i+1;
     345             $currentBool = ($current == $i+1) ? true : false;
     346             if(
     347                 ($num == $leftOffset-1 && $num != 1 && $num != $totalPages)
     348                 || ($num == $leftOffset+$context+1 && $num != 1 && $num != $totalPages)
     349             ) {
     350                 $ret->push(new ArrayData(array(
     351                         "PageNum" => null,
     352                         "Link" => null,
     353                         "CurrentBool" => $currentBool,
     354                         'StartItem' => $i * $this->pageLength + 1,
     355                         'EndItem' => ($i + 1) * $this->pageLength,
     356                         'LinkOrCurrent' => ($this->CurrentPage() == $i+1) ? 'current' : 'link',
     357                     )
     358                 ));
     359             } else if($num == 1 || $num == $totalPages || in_array($num, range($current-$offset,$current+$offset))) {
     360                 $ret->push(new ArrayData(array(
     361                         "PageNum" => $num,
     362                         "Link" => $link,
     363                         "CurrentBool" => $currentBool,
     364                         'StartItem' => $i * $this->pageLength + 1,
     365                         'EndItem' => ($i + 1) * $this->pageLength,
     366                         'LinkOrCurrent' => ($currentBool) ? 'current' : 'link',
     367                     )
     368                 ));
     369             }
     370         }
     371         return $ret;
     372     }
     373
     374     /**
     375      * Returns true if the current page is not the first page.
     376      * @return boolean
     377      */
     378     public function NotFirstPage(){
     379         return $this->CurrentPage() != 1;
     380     }
     381
     382     /**
     383      * Returns true if the current page is not the last page.
     384      * @return boolean
     385      */
     386     public function NotLastPage(){
     387         return $this->CurrentPage() != $this->TotalPages();
     388     }
     389
     390     /**
     391      * Returns true if there is more than one page.
     392      * @return boolean
     393      */
     394     public function MoreThanOnePage(){
     395         return $this->TotalPages() > 1;
     396     }
     397
     398     function FirstItem() {
     399         return isset($this->pageStart) ? $this->pageStart + 1 : 1;
     400     }
     401
     402     function LastItem() {
     403         if(isset($this->pageStart)) {
     404             return min($this->pageStart + $this->pageLength, $this->totalSize);
     405         } else {
     406             return min($this->pageLength, $this->totalSize);
     407         }
     408     }
     409
     410     /**
     411      * Returns the URL of the previous page.
     412      * @return string
     413      */
     414     public function PrevLink() {
     415         if($this->pageStart - $this->pageLength > 0) {
     416             return HTTP::setGetVar($this->paginationGetVar, $this->pageStart - $this->pageLength);
     417         }
     418         if($this->pageStart == $this->pageLength) {
     419             return HTTP::setGetVar($this->paginationGetVar, null);
     420         }
     421     }
     422
     423     /**
     424      * Returns the URL of the next page.
     425      * @return string
     426      */
     427     public function NextLink() {
     428         if($this->pageStart + $this->pageLength < $this->totalSize) {
     429             return HTTP::setGetVar($this->paginationGetVar, $this->pageStart + $this->pageLength);
     430         }
     431     }
     432
     433     /**
     434      * Allows us to use multiple pagination GET variables on the same page (eg. if you have search results and page comments on a single page)
     435      *
     436      * Example: @see PageCommentInterface::Comments()
     437      * @param string $var The variable to go in the GET string (Defaults to 'start')
     438      */
     439     public function setPaginationGetVar($var) {
     440             $this->paginationGetVar = $var;
     441     }
     442
     443     /**
     444      * Add an item to the DataObject Set.
     445      * @param DataObject $item Item to add.
     446      * @param string $key Key to index this DataObject by.
     447      */
     448     public function push($item, $key = null) {
     449         if($key != null) {
     450             unset($this->items[$key]);
     451             $this->items[$key] = $item;
     452         } else {
     453             $this->items[] = $item;
     454         }
     455     }
     456
     457     /**
     458      * Add an item to the beginning of the DataObjectSet
     459      * @param DataObject $item Item to add
     460      * @param string $key Key to index this DataObject by.
     461      */
     462     public function insertFirst($item, $key = null) {
     463         if($key == null) {
     464             array_unshift($this->items, $item);
     465         } else {
     466             $this->items = array_merge(array($key=>$item), $this->items);
     467         }
     468     }
     469
     470     /**
     471      * Insert a DataObject at the beginning of this set.
     472      * @param DataObject $item Item to insert.
     473      */
     474     public function unshift($item) {
     475         $this->insertFirst($item);
     476     }
     477
     478     /**
     479      * Remove a DataObject from the beginning of this set and return it.
     480      * This is the equivalent of pop() but acts on the head of the set.
     481      * Opposite of unshift().
     482      *
     483      * @return DataObject (or null if there are no items in the set)
     484      */
     485     public function shift() {
     486         return array_shift($this->items);
     487     }
     488
     489     /**
     490      * Remove a DataObject from the end of this set and return it.
     491      * This is the equivalent of shift() but acts on the tail of the set.
     492      * Opposite of push().
     493      *
     494      * @return DataObject (or null if there are no items in the set)
     495      */
     496     public function pop() {
     497         return array_pop($this->items);
     498     }
     499
     500     /**
     501      * Remove a DataObject from this set.
     502      * @param DataObject $itemObject Item to remove.
     503      */
     504     public function remove($itemObject) {
     505         foreach($this->items as $key=>$item){
     506             if($item === $itemObject){
     507                 unset($this->items[$key]);
     508             }
     509         }
     510     }
     511
     512     /**
     513      * Replaces $itemOld with $itemNew
     514      *
     515      * @param DataObject $itemOld
     516      * @param DataObject $itemNew
     517      */
     518     public function replace($itemOld, $itemNew) {
     519         foreach($this->items as $key => $item) {
     520             if($item === $itemOld) {
     521                 $this->items[$key] = $itemNew;
     522                 return;
     523             }
     524         }
     525     }
     526
     527     /**
     528      * Merge another set onto the end of this set.
     529      * @param DataObjectSet $anotherSet Set to mege onto this set.
     530      */
     531     public function merge($anotherSet){
     532         if($anotherSet) {
     533             foreach($anotherSet as $item){
     534                 $this->push($item);
     535             }
     536         }
     537     }
     538
     539     /**
     540      * Gets a specific slice of an existing set.
     541      *
     542      * @param int $offset
     543      * @param int $length
     544      * @return DataObjectSet
     545      */
     546     public function getRange($offset, $length) {
     547         $set = array_slice($this->items, (int)$offset, (int)$length);
     548         return new DataObjectSet($set);
     549     }
     550
     551     /**
     552      * Returns an Iterator for this DataObjectSet.
     553      * This function allows you to use DataObjectSets in foreach loops
     554      * @return DataObjectSet_Iterator
     555      */
     556     public function getIterator() {
     557         return new DataObjectSet_Iterator($this->items);
     558     }
     559
     560     /**
     561      * Returns false if the set is empty.
     562      * @return boolean
     563      */
     564     public function exists() {
     565         return (bool)$this->items;
     566     }
     567
     568     /**
     569      * Return the first item in the set.
     570      * @return DataObject
     571      */
     572     public function First() {
     573         if(count($this->items) < 1)
     574             return null;
     575
     576         $keys = array_keys($this->items);
     577         return $this->items[$keys[0]];
     578     }
     579
     580     /**
     581      * Return the last item in the set.
     582      * @return DataObject
     583      */
     584     public function Last() {
     585         if(count($this->items) < 1)
     586             return null;
     587
     588         $keys = array_keys($this->items);
     589         return $this->items[$keys[sizeof($keys)-1]];
     590     }
     591
     592     /**
     593      * Return the total number of items in this dataset.
     594      * @return int
     595      */
     596     public function TotalItems() {
     597         return $this->totalSize ? $this->totalSize : sizeof($this->items);
     598     }
     599
     600     /**
     601      * Returns the actual number of items in this dataset.
     602      * @return int
     603      */
     604     public function Count() {
     605         return sizeof($this->items);
     606     }
     607
     608     /**
     609      * Returns this set as a XHTML unordered list.
     610      * @return string
     611      */
     612     public function UL() {
     613         if($this->items) {
     614             $result = "<ul id=\"Menu1\">\n";
     615             foreach($this->items as $item) {
     616                 $result .= "<li onclick=\"location.href = this.getElementsByTagName('a')[0].href\"><a href=\"$item->Link\">$item->Title</a></li>\n";
     617             }
     618             $result .= "</ul>\n";
     619
     620             return $result;
     621         }
     622     }
     623
     624     /**
     625      * Returns this set as a XHTML unordered list.
     626      * @return string
     627      */
     628     public function forTemplate() {
     629         return $this->UL();
     630     }
     631
     632     /**
     633      * Returns an array of ID => Title for the items in this set.
     634      *
     635      * @param string $index The field to use as a key for the array
     636      * @param string $titleField The field (or method) to get values for the map
     637      * @param string $emptyString Empty option text e.g "(Select one)"
     638      * @param bool $sort Sort the map alphabetically based on the $titleField value
     639      * @return array
     640      */
     641     public function map($index = 'ID', $titleField = 'Title', $emptyString = null, $sort = false) {
     642         $map = array();
     643
     644         if($this->items) {
     645             foreach($this->items as $item) {
     646                 $map[$item->$index] = ($item->hasMethod($titleField)) ? $item->$titleField() : $item->$titleField;
     647             }
     648         }
     649
     650         if($emptyString) $map = array('' => "$emptyString") + $map;
     651         if($sort) asort($map);
     652
     653         return $map;
     654     }
     655
     656     /**
     657      * Find an item in this list where the field $key is equal to $value
     658      * Eg: $doSet->find('ID', 4);
     659      * @return ViewableData The first matching item.
     660      */
     661     public function find($key, $value) {
     662         foreach($this->items as $item) {
     663             if($item->$key == $value) return $item;
     664         }
     665     }
     666
     667     /**
     668      * Return a column of the given field
     669      * @param string $value The field name
     670      * @return array
     671      */
     672     public function column($value = "ID") {
     673         $list = array();
     674         foreach($this->items as $item ){
     675             $list[] = ($item->hasMethod($value)) ? $item->$value() : $item->$value;
     676         }
     677         return $list;
     678     }
     679
     680     /**
     681      * Returns an array of DataObjectSets. The array is keyed by index.
     682      *
     683      * @param string $index The field name to index the array by.
     684      * @return array
     685      */
     686     public function groupBy($index) {
     687         $result = array();
     688         foreach($this->items as $item) {
     689             $key = ($item->hasMethod($index)) ? $item->$index() : $item->$index;
     690
     691             if(!isset($result[$key])) {
     692                 $result[$key] = new DataObjectSet();
     693             }
     694             $result[$key]->push($item);
     695         }
     696         return $result;
     697     }
     698
     699     /**
     700      * Groups the items by a given field.
     701      * Returns a DataObjectSet suitable for use in a nested template.
     702      * @param string $index The field to group by
     703      * @param string $childControl The name of the nested page control
     704      * @return DataObjectSet
     705      */
     706     public function GroupedBy($index, $childControl = "Children") {
     707         $grouped = $this->groupBy($index);
     708         $groupedAsSet = new DataObjectSet();
     709         foreach($grouped as $group) {
     710             $groupedAsSet->push($group->First()->customise(array(
     711                 $childControl => $group
     712             )));
     713         }
     714         return $groupedAsSet;
     715     }
     716
     717     /**
     718      * Returns a nested unordered list out of a "chain" of DataObject-relations,
     719      * using the automagic ComponentSet-relation-methods to find subsequent DataObjectSets.
     720      * The formatting of the list can be different for each level, and is evaluated as an SS-template
     721      * with access to the current DataObjects attributes and methods.
     722      *
     723      * Example: Groups (Level 0, the "calling" DataObjectSet, needs to be queried externally)
     724      * and their Members (Level 1, determined by the Group->Members()-relation).
     725      *
     726      * @param array $nestingLevels
     727      * Defines relation-methods on DataObjects as a string, plus custom
     728      * SS-template-code for the list-output. Use "Root" for the current DataObjectSet (is will not evaluate into
     729      * a function).
     730      * Caution: Don't close the list-elements (determined programatically).
     731      * You need to escape dollar-signs that need to be evaluated as SS-template-code.
     732      * Use $EvenOdd to get appropriate classes for CSS-styling.
     733      * Format:
     734      * array(
     735      *  array(
     736      *      "dataclass" => "Root",
     737      *      "template" => "<li class=\"\$EvenOdd\"><a href=\"admin/crm/show/\$ID\">\$AccountName</a>"
     738      *  ),
     739      *  array(
     740      *      "dataclass" => "GrantObjects",
     741      *      "template" => "<li class=\"\$EvenOdd\"><a href=\"admin/crm/showgrant/\$ID\">#\$GrantNumber: \$TotalAmount.Nice, \$ApplicationDate.ShortMonth \$ApplicationDate.Year</a>"
     742      *  )
     743      * );
     744      * @param string $ulExtraAttributes Extra attributes
     745      *
     746      * @return string Unordered List (HTML)
     747      */
     748     public function buildNestedUL($nestingLevels, $ulExtraAttributes = "") {
     749         return $this->getChildrenAsUL($nestingLevels, 0, "", $ulExtraAttributes);
     750     }
     751
     752     /**
     753      * Gets called recursively on the child-objects of the chain.
     754      *
     755      * @param array $nestingLevels see {@buildNestedUL}
     756      * @param int $level Current nesting level
     757      * @param string $template Template for list item
     758      * @param string $ulExtraAttributes Extra attributes
     759      * @return string
     760      */
     761     public function getChildrenAsUL($nestingLevels, $level = 0, $template = "<li id=\"record-\$ID\" class=\"\$EvenOdd\">\$Title", $ulExtraAttributes = null, &$itemCount = 0) {
     762         $output = "";
     763         $hasNextLevel = false;
     764         $ulExtraAttributes = " $ulExtraAttributes";
     765         $output = "<ul" . eval($ulExtraAttributes) . ">\n";
     766
     767         $currentNestingLevel = $nestingLevels[$level];
     768
     769         // either current or default template
     770         $currentTemplate = (!empty($currentNestingLevel)) ? $currentNestingLevel['template'] : $template;
     771         $myViewer = SSViewer::fromString($currentTemplate);
     772
     773         if(isset($nestingLevels[$level+1]['dataclass'])){
     774             $childrenMethod = $nestingLevels[$level+1]['dataclass'];
     775         }
     776         // sql-parts
     777
     778         $filter = (isset($nestingLevels[$level+1]['filter'])) ? $nestingLevels[$level+1]['filter'] : null;
     779         $sort = (isset($nestingLevels[$level+1]['sort'])) ? $nestingLevels[$level+1]['sort'] : null;
     780         $join = (isset($nestingLevels[$level+1]['join'])) ? $nestingLevels[$level+1]['join'] : null;
     781         $limit = (isset($nestingLevels[$level+1]['limit'])) ? $nestingLevels[$level+1]['limit'] : null;
     782         $having = (isset($nestingLevels[$level+1]['having'])) ? $nestingLevels[$level+1]['having'] : null;
     783
     784         foreach($this as $parent) {
     785             $evenOdd = ($itemCount % 2 == 0) ? "even" : "odd";
     786             $parent->setField('EvenOdd', $evenOdd);
     787             $template = $myViewer->process($parent);
     788
     789             // if no output is selected, fall back to the id to keep the item "clickable"
     790             $output .= $template . "\n";
     791
     792             if(isset($childrenMethod)) {
     793                 // workaround for missing groupby/having-parameters in instance_get
     794                 // get the dataobjects for the next level
     795                 $children = $parent->$childrenMethod($filter, $sort, $join, $limit, $having);
     796                 if($children) {
     797                     $output .= $children->getChildrenAsUL($nestingLevels, $level+1, $currentTemplate, $ulExtraAttributes);
     798                 }
     799             }
     800             $output .= "</li>\n";
     801             $itemCount++;
     802         }
     803
     804         $output .= "</ul>\n";
     805
     806         return $output;
     807     }
     808
     809     /**
     810     * Sorts the current DataObjectSet instance.
     811     * @param string $fieldname The name of the field on the DataObject that you wish to sort the set by.
     812     * @param string $direction Direction to sort by, either "ASC" or "DESC".
     813     */
     814     public function sort($fieldname, $direction = "ASC") {
     815         if($this->items) {
     816             if (preg_match('/(.+?)(\s+?)(A|DE)SC$/', $fieldname, $matches)) {
     817                 $fieldname = $matches[1];
     818                 $direction = $matches[3].'SC';
     819             }
     820             column_sort($this->items, $fieldname, $direction, false);
     821         }
     822     }
     823
     824     /**
     825     * Remove duplicates from this set based on the dataobjects field.
     826     * Assumes all items contained in the set all have that field.
     827     * @param string $field the field to check for duplicates
     828     */
     829     public function removeDuplicates($field = 'ID') {
     830         $exists = array();
     831         foreach($this->items as $key => $item) {
     832             if(isset($exists[$fullkey = ClassInfo::baseDataClass($item) . ":" . $item->$field])) {
     833                 unset($this->items[$key]);
     834             }
     835             $exists[$fullkey] = true;
     836         }
     837     }
     838
     839     /**
     840      * Returns information about this set in HTML format for debugging.
     841      * @return string
     842      */
     843     public function debug() {
     844         $val = "<h2>" . $this->class . "</h2><ul>";
     845         foreach($this as $item) {
     846             $val .= "<li style=\"list-style-type: disc; margin-left: 20px\">" . Debug::text($item) . "</li>";
     847         }
     848         $val .= "</ul>";
     849         return $val;
     850     }
     851
     852     /**
     853      * Groups the set by $groupField and returns the parent of each group whose class
     854      * is $groupClassName. If $collapse is true, the group will be collapsed up until an ancestor with the
     855      * given class is found.
     856      * @param string $groupField The field to group by.
     857      * @param string $groupClassName Classname.
     858      * @param string $sortParents SORT clause to insert into the parents SQL.
     859      * @param string $parentField Parent field.
     860      * @param boolean $collapse Collapse up until an ancestor with the given class is found.
     861      * @param string $requiredParents Required parents
     862      * @return DataObjectSet
     863      */
     864     public function groupWithParents($groupField, $groupClassName, $sortParents = null, $parentField = 'ID', $collapse = false, $requiredParents = null) {
     865         // Each item in this DataObjectSet is grouped into a multidimensional array
     866         // indexed by it's parent. The parent IDs are later used to find the parents
     867         // that make up the returned set.
     868         $groupedSet = array();
     869
     870         // Array to store the subgroups matching the requirements
     871         $resultsArray = array();
     872
     873         // Put this item into the array indexed by $groupField.
     874         // the keys are later used to retrieve the top-level records
     875         foreach( $this->items as $item ) {
     876             $groupedSet[$item->$groupField][] = $item;
     877         }
     878
     879         $parentSet = null;
     880
     881         // retrieve parents for this set
     882
     883         // TODO How will we collapse the hierarchy to bridge the gap?
     884
     885         // if collapse is specified, then find the most direct ancestor of type
     886         // $groupClassName
     887         if($collapse) {
     888             // The most direct ancestors with the type $groupClassName
     889             $parentSet = array();
     890
     891             // get direct parents
     892             $parents = DataObject::get( 'SiteTree', "\"SiteTree\".\"$parentField\" IN( " . implode( ",", array_keys( $groupedSet ) ) . ")", $sortParents );
     893
     894             // for each of these parents...
     895             foreach($parents as $parent) {
     896                 // store the old parent ID. This is required to change the grouped items
     897                 // in the $groupSet array
     898                 $oldParentID = $parent->ID;
     899
     900                 // get the parental stack
     901                 $parentObjects= $parent->parentStack();
     902                 $parentStack = array();
     903
     904                 foreach( $parentObjects as $parentObj )
     905                     $parentStack[] = $parentObj->ID;
     906
     907                 // is some particular IDs are required, then get the intersection
     908                 if($requiredParents && count($requiredParents)) {
     909                     $parentStack = array_intersect($requiredParents, $parentStack);
     910                 }
     911
     912                 $newParent = null;
     913
     914                 // If there are no parents, the group can be omitted
     915                 if(empty($parentStack)) {
     916                     $newParent = new DataObjectSet();
     917                 } else {
     918                     $newParent = DataObject::get_one( $groupClassName, "\"SiteTree\".\"$parentField\" IN( " . implode( ",", $parentStack ) . ")" );
     919                 }
     920
     921                 // change each of the descendant's association from the old parent to
     922                 // the new parent. This effectively collapses the hierarchy
     923                 foreach( $groupedSet[$oldParentID] as $descendant ) {
     924                     $groupedSet[$newParent->ID][] = $descendant;
     925                 }
     926
     927                 // Add the most direct ancestor of type $groupClassName
     928                 $parentSet[] = $newParent;
     929             }
     930         // otherwise get the parents of these items
     931         } else {
     932
     933             $requiredIDs = array_keys( $groupedSet );
     934
     935             if( $requiredParents && cont($requiredParents)) {
     936                 $requiredIDs = array_intersect($requiredParents, $requiredIDs);
     937             }
     938
     939             if(empty($requiredIDs)) {
     940                 $parentSet = new DataObjectSet();
     941             } else {
     942                 $parentSet = DataObject::get( $groupClassName, "\"$groupClassName\".\"$parentField\" IN( " . implode( ",", $requiredIDs ) . ")", $sortParents );
     943             }
     944
     945             if ($parentSet) {
     946                 $parentSet = $parentSet->toArray();
     947             }
     948         }
     949
     950         if ($parentSet) {
     951             foreach($parentSet as $parent) {
     952                 $resultsArray[] = $parent->customise(array(
     953                     "GroupItems" => new DataObjectSet($groupedSet[$parent->$parentField])
     954                 ));
     955             }
     956         }
     957
     958         return new DataObjectSet($resultsArray);
     959     }
     960
     961     /**
     962      * Add a field to this set without writing it to the database
     963      * @param DataObject $field Field to add
     964      */
     965     function addWithoutWrite($field) {
     966         $this->items[] = $field;
     967     }
     968
     969     /**
     970      * Returns true if the DataObjectSet contains all of the IDs givem
     971      * @param $idList An array of object IDs
     972      */
     973     function containsIDs($idList) {
     974         foreach($idList as $item) $wants[$item] = true;
     975         foreach($this->items as $item) if($item) unset($wants[$item->ID]);
     976         return !$wants;
     977     }
     978
     979     /**
     980      * Returns true if the DataObjectSet contains all of and *only* the IDs given.
     981      * Note that it won't like duplicates very much.
     982      * @param $idList An array of object IDs
     983      */
     984     function onlyContainsIDs($idList) {
     985         return $this->containsIDs($idList) && sizeof($idList) == sizeof($this->items);
     986     }
     987
     988     public function hasValue($field = false, $arguments = null, $cache = true) {
     989         if ($field)
     990             return parent::hasValue($field, $arguments, $cache);
     991         return ($this->Count() > 0);
     992     }
     993 }
     994
     995 /**
     996  * Sort a 2D array by particular column.
     997  * @param array $data The array to sort.
     998  * @param string $column The name of the column you wish to sort by.
     999  * @param string $direction Direction to sort by, either "ASC" or "DESC".
    1000  * @param boolean $preserveIndexes Preserve indexes
    1001  */
    1002 function column_sort(&$data, $column, $direction = "ASC", $preserveIndexes = true) {
    1003     global $column_sort_field, $column_sort_multiplier;
    1004
    1005     // We have to keep numeric diretions for legacy
    1006     if(is_numeric($direction)) {
    1007         $column_sort_multiplier = $direction;
    1008     } elseif($direction == "ASC") {
    1009         $column_sort_multiplier = 1;
    1010     } elseif($direction == "DESC") {
    1011         $column_sort_multiplier = -1;
    1012     } else {
    1013         $column_sort_multiplier = 0;
    1014     }
    1015     $column_sort_field = $column;
    1016     if($preserveIndexes) {
    1017         uasort($data, "column_sort_callback_basic");
    1018     } else {
    1019         usort($data, "column_sort_callback_basic");
    1020     }
    1021 }
    1022
    1023 /**
    1024  * Callback used by column_sort
    1025  */
    1026 function column_sort_callback_basic($a, $b) {
    1027     global $column_sort_field, $column_sort_multiplier;
    1028
    1029     if($a->$column_sort_field == $b->$column_sort_field) {
    1030         $result  = 0;
    1031     } else {
    1032         $result = ($a->$column_sort_field < $b->$column_sort_field) ? -1 * $column_sort_multiplier : 1 * $column_sort_multiplier;
    1033     }
    1034
    1035     return $result;
    1036 }
    1037
    1038 /**
    1039  * An Iterator for a DataObjectSet
    1040  *
    1041  * @package sapphire
    1042  * @subpackage model
    1043  */
    1044 class DataObjectSet_Iterator implements Iterator {
    1045     function __construct($items) {
    1046         $this->items = $items;
    1047
    1048         $this->current = $this->prepareItem(current($this->items));
    1049     }
    1050
    1051     /**
    1052      * Prepare an item taken from the internal array for
    1053      * output by this iterator.  Ensures that it is an object.
    1054      * @param DataObject $item Item to prepare
    1055      * @return DataObject
    1056      */
    1057     protected function prepareItem($item) {
    1058         if(is_object($item)) {
    1059             $item->iteratorProperties(key($this->items), sizeof($this->items));
    1060         }
    1061         // This gives some reliablity but it patches over the root cause of the bug...
    1062         // else if(key($this->items) !== null) $item = new ViewableData();
    1063         return $item;
    1064     }
    1065
    1066
    1067     /**
    1068      * Return the current object of the iterator.
    1069      * @return DataObject
    1070      */
    1071     public function current() {
    1072         return $this->current;
    1073     }
    1074
    1075     /**
    1076      * Return the key of the current object of the iterator.
    1077      * @return mixed
    1078      */
    1079     public function key() {
    1080         return key($this->items);
    1081     }
    1082
    1083     /**
    1084      * Return the next item in this set.
    1085      * @return DataObject
    1086      */
    1087     public function next() {
    1088         $this->current = $this->prepareItem(next($this->items));
    1089         return $this->current;
    1090     }
    1091
    1092     /**
    1093      * Rewind the iterator to the beginning of the set.
    1094      * @return DataObject The first item in the set.
    1095      */
    1096     public function rewind() {
    1097         $this->current = $this->prepareItem(reset($this->items));
    1098         return $this->current;
    1099     }
    1100
    1101     /**
    1102      * Check the iterator is pointing to a valid item in the set.
    1103      * @return boolean
    1104      */
    1105     public function valid() {
    1106         return $this->current !== false;
    1107     }
    1108
    1109     /**
    1110      * Return the next item in this set without progressing the iterator.
    1111      * @return DataObject
    1112      */
    1113     public function peekNext() {
    1114         return $this->getOffset(1);
    1115     }
    1116
    1117     /**
    1118      * Return the prvious item in this set, without affecting the iterator.
    1119      * @return DataObject
    1120      */
    1121     public function peekPrev() {
    1122         return $this->getOffset(-1);
    1123     }
    1124
    1125     /**
    1126      * Return the object in this set offset by $offset from the iterator pointer.
    1127      * @param int $offset The offset.
    1128      * @return DataObject|boolean DataObject of offset item, or boolean FALSE if not found
    1129      */
    1130     public function getOffset($offset) {
    1131         $keys = array_keys($this->items);
    1132         foreach($keys as $i => $key) {
    1133             if($key == key($this->items)) break;
    1134         }
    1135
    1136         if(isset($keys[$i + $offset])) {
    1137             $requiredKey = $keys[$i + $offset];
    1138             return $this->items[$requiredKey];
    1139         }
    1140
    1141         return false;
    1142     }
    1143 }
    1144
    1145 ?>

[Raise a SilverStripe Framework issue/bug](https://github.com/silverstripe/silverstripe-framework/issues/new)
- [Raise a SilverStripe CMS issue/bug](https://github.com/silverstripe/silverstripe-cms/issues/new)
- Please use the Silverstripe Forums to ask development related questions. -