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 * @param int $itemCount Max item count
760 * @return string
761 */
762 public function getChildrenAsUL($nestingLevels, $level = 0, $template = "<li id=\"record-\$ID\" class=\"\$EvenOdd\">\$Title", $ulExtraAttributes = null, &$itemCount = 0) {
763 $output = "";
764 $hasNextLevel = false;
765 $ulExtraAttributes = " $ulExtraAttributes";
766 $output = "<ul" . eval($ulExtraAttributes) . ">\n";
767
768 $currentNestingLevel = $nestingLevels[$level];
769
770 // either current or default template
771 $currentTemplate = (!empty($currentNestingLevel)) ? $currentNestingLevel['template'] : $template;
772 $myViewer = SSViewer::fromString($currentTemplate);
773
774 if(isset($nestingLevels[$level+1]['dataclass'])){
775 $childrenMethod = $nestingLevels[$level+1]['dataclass'];
776 }
777 // sql-parts
778
779 $filter = (isset($nestingLevels[$level+1]['filter'])) ? $nestingLevels[$level+1]['filter'] : null;
780 $sort = (isset($nestingLevels[$level+1]['sort'])) ? $nestingLevels[$level+1]['sort'] : null;
781 $join = (isset($nestingLevels[$level+1]['join'])) ? $nestingLevels[$level+1]['join'] : null;
782 $limit = (isset($nestingLevels[$level+1]['limit'])) ? $nestingLevels[$level+1]['limit'] : null;
783 $having = (isset($nestingLevels[$level+1]['having'])) ? $nestingLevels[$level+1]['having'] : null;
784
785 foreach($this as $parent) {
786 $evenOdd = ($itemCount % 2 == 0) ? "even" : "odd";
787 $parent->setField('EvenOdd', $evenOdd);
788 $template = $myViewer->process($parent);
789
790 // if no output is selected, fall back to the id to keep the item "clickable"
791 $output .= $template . "\n";
792
793 if(isset($childrenMethod)) {
794 // workaround for missing groupby/having-parameters in instance_get
795 // get the dataobjects for the next level
796 $children = $parent->$childrenMethod($filter, $sort, $join, $limit, $having);
797 if($children) {
798 $output .= $children->getChildrenAsUL($nestingLevels, $level+1, $currentTemplate, $ulExtraAttributes);
799 }
800 }
801 $output .= "</li>\n";
802 $itemCount++;
803 }
804
805 $output .= "</ul>\n";
806
807 return $output;
808 }
809
810 /**
811 * Sorts the current DataObjectSet instance.
812 * @param string $fieldname The name of the field on the DataObject that you wish to sort the set by.
813 * @param string $direction Direction to sort by, either "ASC" or "DESC".
814 */
815 public function sort($fieldname, $direction = "ASC") {
816 if($this->items) {
817 if (preg_match('/(.+?)(\s+?)(A|DE)SC$/', $fieldname, $matches)) {
818 $fieldname = $matches[1];
819 $direction = $matches[3].'SC';
820 }
821 column_sort($this->items, $fieldname, $direction, false);
822 }
823 }
824
825 /**
826 * Remove duplicates from this set based on the dataobjects field.
827 * Assumes all items contained in the set all have that field.
828 * @param string $field the field to check for duplicates
829 */
830 public function removeDuplicates($field = 'ID') {
831 $exists = array();
832 foreach($this->items as $key => $item) {
833 if(isset($exists[$fullkey = ClassInfo::baseDataClass($item) . ":" . $item->$field])) {
834 unset($this->items[$key]);
835 }
836 $exists[$fullkey] = true;
837 }
838 }
839
840 /**
841 * Returns information about this set in HTML format for debugging.
842 * @return string
843 */
844 public function debug() {
845 $val = "<h2>" . $this->class . "</h2><ul>";
846 foreach($this as $item) {
847 $val .= "<li style=\"list-style-type: disc; margin-left: 20px\">" . Debug::text($item) . "</li>";
848 }
849 $val .= "</ul>";
850 return $val;
851 }
852
853 /**
854 * Groups the set by $groupField and returns the parent of each group whose class
855 * is $groupClassName. If $collapse is true, the group will be collapsed up until an ancestor with the
856 * given class is found.
857 * @param string $groupField The field to group by.
858 * @param string $groupClassName Classname.
859 * @param string $sortParents SORT clause to insert into the parents SQL.
860 * @param string $parentField Parent field.
861 * @param boolean $collapse Collapse up until an ancestor with the given class is found.
862 * @param string $requiredParents Required parents
863 * @return DataObjectSet
864 */
865 public function groupWithParents($groupField, $groupClassName, $sortParents = null, $parentField = 'ID', $collapse = false, $requiredParents = null) {
866 // Each item in this DataObjectSet is grouped into a multidimensional array
867 // indexed by it's parent. The parent IDs are later used to find the parents
868 // that make up the returned set.
869 $groupedSet = array();
870
871 // Array to store the subgroups matching the requirements
872 $resultsArray = array();
873
874 // Put this item into the array indexed by $groupField.
875 // the keys are later used to retrieve the top-level records
876 foreach( $this->items as $item ) {
877 $groupedSet[$item->$groupField][] = $item;
878 }
879
880 $parentSet = null;
881
882 // retrieve parents for this set
883
884 // TODO How will we collapse the hierarchy to bridge the gap?
885
886 // if collapse is specified, then find the most direct ancestor of type
887 // $groupClassName
888 if($collapse) {
889 // The most direct ancestors with the type $groupClassName
890 $parentSet = array();
891
892 // get direct parents
893 $parents = DataObject::get( 'SiteTree', "\"SiteTree\".\"$parentField\" IN( " . implode( ",", array_keys( $groupedSet ) ) . ")", $sortParents );
894
895 // for each of these parents...
896 foreach($parents as $parent) {
897 // store the old parent ID. This is required to change the grouped items
898 // in the $groupSet array
899 $oldParentID = $parent->ID;
900
901 // get the parental stack
902 $parentObjects= $parent->parentStack();
903 $parentStack = array();
904
905 foreach( $parentObjects as $parentObj )
906 $parentStack[] = $parentObj->ID;
907
908 // is some particular IDs are required, then get the intersection
909 if($requiredParents && count($requiredParents)) {
910 $parentStack = array_intersect($requiredParents, $parentStack);
911 }
912
913 $newParent = null;
914
915 // If there are no parents, the group can be omitted
916 if(empty($parentStack)) {
917 $newParent = new DataObjectSet();
918 } else {
919 $newParent = DataObject::get_one( $groupClassName, "\"SiteTree\".\"$parentField\" IN( " . implode( ",", $parentStack ) . ")" );
920 }
921
922 // change each of the descendant's association from the old parent to
923 // the new parent. This effectively collapses the hierarchy
924 foreach( $groupedSet[$oldParentID] as $descendant ) {
925 $groupedSet[$newParent->ID][] = $descendant;
926 }
927
928 // Add the most direct ancestor of type $groupClassName
929 $parentSet[] = $newParent;
930 }
931 // otherwise get the parents of these items
932 } else {
933
934 $requiredIDs = array_keys( $groupedSet );
935
936 if( $requiredParents && cont($requiredParents)) {
937 $requiredIDs = array_intersect($requiredParents, $requiredIDs);
938 }
939
940 if(empty($requiredIDs)) {
941 $parentSet = new DataObjectSet();
942 } else {
943 $parentSet = DataObject::get( $groupClassName, "\"$groupClassName\".\"$parentField\" IN( " . implode( ",", $requiredIDs ) . ")", $sortParents );
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 *
998 * @param array $data The array to sort.
999 * @param string $column The name of the column you wish to sort by.
1000 * @param string $direction Direction to sort by, either "ASC" or "DESC".
1001 * @param boolean $preserveIndexes Preserve indexes
1002 */
1003 function column_sort(&$data, $column, $direction = "ASC", $preserveIndexes = true) {
1004 global $column_sort_field, $column_sort_multiplier;
1005
1006 // We have to keep numeric diretions for legacy
1007 if(is_numeric($direction)) {
1008 $column_sort_multiplier = $direction;
1009 } elseif($direction == "ASC") {
1010 $column_sort_multiplier = 1;
1011 } elseif($direction == "DESC") {
1012 $column_sort_multiplier = -1;
1013 } else {
1014 $column_sort_multiplier = 0;
1015 }
1016 $column_sort_field = $column;
1017 if($preserveIndexes) {
1018 uasort($data, "column_sort_callback_basic");
1019 } else {
1020 usort($data, "column_sort_callback_basic");
1021 }
1022 }
1023
1024 /**
1025 * Callback used by column_sort
1026 */
1027 function column_sort_callback_basic($a, $b) {
1028 global $column_sort_field, $column_sort_multiplier;
1029
1030 if($a->$column_sort_field == $b->$column_sort_field) {
1031 $result = 0;
1032 } else {
1033 $result = ($a->$column_sort_field < $b->$column_sort_field) ? -1 * $column_sort_multiplier : 1 * $column_sort_multiplier;
1034 }
1035
1036 return $result;
1037 }
1038
1039 /**
1040 * An Iterator for a DataObjectSet
1041 *
1042 * @package sapphire
1043 * @subpackage model
1044 */
1045 class DataObjectSet_Iterator implements Iterator {
1046 function __construct($items) {
1047 $this->items = $items;
1048
1049 $this->current = $this->prepareItem(current($this->items));
1050 }
1051
1052 /**
1053 * Prepare an item taken from the internal array for
1054 * output by this iterator. Ensures that it is an object.
1055 * @param DataObject $item Item to prepare
1056 * @return DataObject
1057 */
1058 protected function prepareItem($item) {
1059 if(is_object($item)) {
1060 $item->iteratorProperties(key($this->items), sizeof($this->items));
1061 }
1062 // This gives some reliablity but it patches over the root cause of the bug...
1063 // else if(key($this->items) !== null) $item = new ViewableData();
1064 return $item;
1065 }
1066
1067
1068 /**
1069 * Return the current object of the iterator.
1070 * @return DataObject
1071 */
1072 public function current() {
1073 return $this->current;
1074 }
1075
1076 /**
1077 * Return the key of the current object of the iterator.
1078 * @return mixed
1079 */
1080 public function key() {
1081 return key($this->items);
1082 }
1083
1084 /**
1085 * Return the next item in this set.
1086 * @return DataObject
1087 */
1088 public function next() {
1089 $this->current = $this->prepareItem(next($this->items));
1090 return $this->current;
1091 }
1092
1093 /**
1094 * Rewind the iterator to the beginning of the set.
1095 * @return DataObject The first item in the set.
1096 */
1097 public function rewind() {
1098 $this->current = $this->prepareItem(reset($this->items));
1099 return $this->current;
1100 }
1101
1102 /**
1103 * Check the iterator is pointing to a valid item in the set.
1104 * @return boolean
1105 */
1106 public function valid() {
1107 return $this->current !== false;
1108 }
1109
1110 /**
1111 * Return the next item in this set without progressing the iterator.
1112 * @return DataObject
1113 */
1114 public function peekNext() {
1115 return $this->getOffset(1);
1116 }
1117
1118 /**
1119 * Return the prvious item in this set, without affecting the iterator.
1120 * @return DataObject
1121 */
1122 public function peekPrev() {
1123 return $this->getOffset(-1);
1124 }
1125
1126 /**
1127 * Return the object in this set offset by $offset from the iterator pointer.
1128 * @param int $offset The offset.
1129 * @return DataObject|boolean DataObject of offset item, or boolean FALSE if not found
1130 */
1131 public function getOffset($offset) {
1132 $keys = array_keys($this->items);
1133 foreach($keys as $i => $key) {
1134 if($key == key($this->items)) break;
1135 }
1136
1137 if(isset($keys[$i + $offset])) {
1138 $requiredKey = $keys[$i + $offset];
1139 return $this->items[$requiredKey];
1140 }
1141
1142 return false;
1143 }
1144 }
1145
1146 ?>