Ensuring tests pass after bringing back support for db DSN connection. Rel. [501...
[akelos.git] / lib / AkActiveRecord / AkActsAsBehaviours / AkActsAsNestedSet.php
blob6c8d7d880a340b8378c741fb63e37095223c674a
1 <?php
2 /* vim: set expandtab tabstop=4 shiftwidth=4 softtabstop=4: */
4 // +----------------------------------------------------------------------+
5 // | Akelos Framework - http://www.akelos.org |
6 // +----------------------------------------------------------------------+
7 // | Copyright (c) 2002-2006, Akelos Media, S.L. & Bermi Ferrer Martinez |
8 // | Released under the GNU Lesser General Public License, see LICENSE.txt|
9 // +----------------------------------------------------------------------+
11 /**
12 * @package ActiveRecord
13 * @subpackage Behaviours
14 * @author Bermi Ferrer <bermi a.t akelos c.om>
15 * @author Jean-Christophe Michel, Symétrie
16 * @copyright Copyright (c) 2002-2006, Akelos Media, S.L. http://www.akelos.org
17 * @license GNU Lesser General Public License <http://www.gnu.org/copyleft/lesser.html>
20 require_once(AK_LIB_DIR.DS.'AkActiveRecord'.DS.'AkObserver.php');
22 class AkActsAsNestedSet extends AkObserver
25 /**
26 * This acts provides Nested Set functionality. Nested Set is similiar to Tree, but with
27 * the added feature that you can select the children and all of it's descendants with
28 * a single query. A good use case for this is a threaded post system, where you want
29 * to display every reply to a comment without multiple selects.
31 * A google search for "Nested Set" should point you in the direction to explain the
32 * data base theory. I figured a bunch of this from
33 * http://threebit.net/tutorials/nestedset/tutorial1.html
35 * Instead of picturing a leaf node structure with child pointing back to their parent,
36 * the best way to imagine how this works is to think of the parent entity surrounding all
37 * of it's children, and it's parent surrounding it, etc. Assuming that they are lined up
38 * horizontally, we store the left and right boundaries in the database.
40 * Imagine:
41 * root
42 * |_ Child 1
43 * |_ Child 1.1
44 * |_ Child 1.2
45 * |_ Child 2
46 * |_ Child 2.1
47 * |_ Child 2.2
49 * If my circles in circles description didn't make sense, check out this sweet
50 * ASCII art:
52 * ___________________________________________________________________
53 * | Root |
54 * | ____________________________ ____________________________ |
55 * | | Child 1 | | Child 2 | |
56 * | | __________ _________ | | __________ _________ | |
57 * | | | C 1.1 | | C 1.2 | | | | C 2.1 | | C 2.2 | | |
58 * 1 2 3_________4 5________6 7 8 9_________10 11_______12 13 14
59 * | |___________________________| |___________________________| |
60 * |___________________________________________________________________|
62 * The numbers represent the left and right boundaries. The table them might
63 * look like this:
64 * ID | PARENT | LEFT | RIGHT | DATA
65 * 1 | 0 | 1 | 14 | root
66 * 2 | 1 | 2 | 7 | Child 1
67 * 3 | 2 | 3 | 4 | Child 1.1
68 * 4 | 2 | 5 | 6 | Child 1.2
69 * 5 | 1 | 8 | 13 | Child 2
70 * 6 | 5 | 9 | 10 | Child 2.1
71 * 7 | 5 | 11 | 12 | Child 2.2
73 * So, to get all children of an entry, you
74 * SELECT * WHERE CHILD.LEFT IS BETWEEN PARENT.LEFT AND PARENT.RIGHT
76 * To get the count, it's (LEFT - RIGHT + 1)/2, etc.
78 * To get the direct parent, it falls back to using the PARENT_ID field.
80 * There are instance methods for all of these.
82 * The structure is good if you need to group things together; the downside is that
83 * keeping data integrity is a pain, and both adding and removing and entry
84 * require a full table write.
86 * This sets up a beforeDestroy() trigger to prune the tree correctly if one of it's
87 * elements gets deleted.
91 /**
92 * Configuration options are:
94 * * +parent_column+ - specifies the column name to use for keeping the position integer (default: parent_id)
95 * * +left_column+ - column name for left boundary data, default "lft"
96 * * +right_column+ - column name for right boundary data, default "rgt"
97 * * +scope+ - restricts what is to be considered a list.
98 * Example: <tt>actsAsList(array('scope' => array('todo_list_id = ? AND completed = 0',$todo_list_id)));</tt>
101 var $_scope_condition;
102 var $_left_column_name = 'lft';
103 var $_right_column_name = 'rgt';
104 var $_parent_column_name = 'parent_id';
106 var $_ActiveRecordInstance;
108 function AkActsAsNestedSet(&$ActiveRecordInstance)
110 $this->_ActiveRecordInstance =& $ActiveRecordInstance;
113 function init($options = array())
115 empty($options['parent_column']) ? null : ($this->_parent_column_name = $options['parent_column']);
116 empty($options['left_column']) ? null : ($this->_left_column_name = $options['left_column']);
117 empty($options['right_column']) ? null : ($this->_right_column_name = $options['right_column']);
118 empty($options['scope']) ? null : $this->setScopeCondition($options['scope']);
119 return $this->_ensureIsActiveRecordInstance($this->_ActiveRecordInstance);
123 function _ensureIsActiveRecordInstance(&$ActiveRecordInstance)
125 if(is_object($ActiveRecordInstance) && method_exists($ActiveRecordInstance,'actsLike')){
126 $this->_ActiveRecordInstance =& $ActiveRecordInstance;
127 if(!$this->_ActiveRecordInstance->hasColumn($this->_parent_column_name) || !$this->_ActiveRecordInstance->hasColumn($this->_left_column_name) || !$this->_ActiveRecordInstance->hasColumn($this->_right_column_name)){
128 trigger_error(Ak::t(
129 'The following columns are required in the table "%table" for the model "%model" to act as a Nested Set: "%columns".',array(
130 '%columns'=>$this->getParentColumnName().', '.$this->getLeftColumnName().', '.$this->getRightColumnName(),'%table'=>$this->_ActiveRecordInstance->getTableName(),'%model'=>$this->_ActiveRecordInstance->getModelName())),E_USER_ERROR);
131 unset($this->_ActiveRecordInstance->nested_set);
132 return false;
133 }else{
134 $this->observe(&$ActiveRecordInstance);
136 }else{
137 trigger_error(Ak::t('You are trying to set an object that is not an active record.'), E_USER_ERROR);
138 return false;
140 return true;
143 function reloadActiveRecordInstance(&$nodeInstance)
145 AK_PHP5 ? null : $nodeInstance->nested_set->_ensureIsActiveRecordInstance($nodeInstance);
148 function getType()
150 return 'nested set';
153 function getScopeCondition()
155 if (!empty($this->variable_scope_condition)){
156 return $this->_ActiveRecordInstance->_getVariableSqlCondition($this->variable_scope_condition);
158 // True condition in case we don't have a scope
159 }elseif(empty($this->scope_condition) && empty($this->scope)){
160 $this->scope_condition = ($this->_ActiveRecordInstance->_db->type() == 'postgre') ? 'true' : '1';
161 }elseif (!empty($this->scope)){
162 $this->setScopeCondition(join(' AND ',array_map(array(&$this,'getScopedColumn'),(array)$this->scope)));
164 return $this->scope_condition;
168 function setScopeCondition($scope_condition)
170 if(!is_array($scope_condition) && strstr($scope_condition, '?')){
171 $this->variable_scope_condition = $scope_condition;
172 }else{
173 $this->scope_condition = $scope_condition;
177 function getScopedColumn($column)
179 if($this->_ActiveRecordInstance->hasColumn($column)){
180 $value = $this->_ActiveRecordInstance->get($column);
181 $condition = $this->_ActiveRecordInstance->getAttributeCondition($value);
182 $value = $this->_ActiveRecordInstance->castAttributeForDatabase($column, $value);
183 return $column.' '.str_replace('?', $value, $condition);
184 }else{
185 return $column;
189 function getLeftColumnName()
191 return $this->_left_column_name;
193 function setLeftColumnName($left_column_name)
195 $this->_left_column_name = $left_column_name;
198 function getRightColumnName()
200 return $this->_right_column_name;
202 function setRightColumnName($right_column_name)
204 $this->_right_column_name = $right_column_name;
208 function getParentColumnName()
210 return $this->_parent_column_name;
212 function setParentColumnName($parent_column_name)
214 $this->_parent_column_name = $parent_column_name;
218 * Returns true is this is a root node.
220 function isRoot()
222 $left_id = $this->_ActiveRecordInstance->get($this->getLeftColumnName());
223 return ($this->_ActiveRecordInstance->get($this->getParentColumnName()) == null) && ($left_id == 1) && ($this->_ActiveRecordInstance->get($this->getRightColumnName()) > $left_id);
227 * Returns true is this is a child node
229 function isChild()
231 $parent_id = $this->_ActiveRecordInstance->get($this->getParentColumnName());
232 $left_id = $this->_ActiveRecordInstance->get($this->getLeftColumnName());
233 return !($parent_id == 0 || is_null($parent_id)) && ($left_id > 1) && ($this->_ActiveRecordInstance->get($this->getRightColumnName()) > $left_id);
237 * Returns true if we have no idea what this is
239 function isUnknown()
241 return !$this->isRoot() && !$this->isChild();
245 * Added a child to this object in the tree. If this object hasn't been initialized,
246 * it gets set up as a root node. Otherwise, this method will update all of the
247 * other elements in the tree and shift them to the right. Keeping everything
248 * balanced.
250 function addChild( &$child )
252 $self =& $this->_ActiveRecordInstance;
253 $self->reload();
254 $child->reload();
255 $left_column = $this->getLeftColumnName();
256 $right_column = $this->getRightColumnName();
257 $parent_column = $this->getParentColumnName();
259 if ($child->nested_set->isRoot()){
260 trigger_error(Ak::t("Adding sub-tree isn't currently supported"),E_USER_ERROR);
261 }elseif ( (is_null($self->get($left_column))) || (is_null($self->get($right_column))) ){
262 // Looks like we're now the root node! Woo
263 $self->set($left_column, 1);
264 $self->set($right_column, 4);
266 $self->transactionStart();
267 // What do to do about validation?
268 if(!$self->save()){
269 $self->transactionFail();
270 $self->transactionComplete();
271 return false;
274 $child->set($parent_column, $self->getId());
275 $child->set($left_column, 2);
276 $child->set($right_column, 3);
278 if(!$child->save()){
279 $self->transactionFail();
280 $self->transactionComplete();
281 return false;
283 $self->transactionComplete();
284 return $child;
285 }else{
286 // OK, we need to add and shift everything else to the right
287 $child->set($parent_column, $self->getId());
288 $right_bound = $self->get($right_column);
289 $child->set($left_column, $right_bound);
290 $child->set($right_column, $right_bound +1);
291 $self->set($right_column, $self->get($right_column) + 2);
293 $self->transactionStart();
294 $self->updateAll( "$left_column = ($left_column + 2)", $this->getScopeCondition()." AND $left_column >= $right_bound" );
295 $self->updateAll( "$right_column = ($right_column + 2)", $this->getScopeCondition()." AND $right_column >= $right_bound" );
296 $self->save();
297 $child->save();
298 $this->reloadActiveRecordInstance($child);
299 $self->transactionComplete();
300 return $child;
306 * Returns the parent Object
308 function &getParent()
310 if(!$this->isChild()){
311 $result = false;
312 }else{
313 $result =& $this->_ActiveRecordInstance->find(
314 // str_replace(array_keys($options['conditions']), array_values($this->getSanitizedConditionsArray($options['conditions'])),$pattern);
315 'first', array('conditions' => " ".$this->getScopeCondition()." AND ".$this->_ActiveRecordInstance->getPrimaryKey()." = ".$this->_ActiveRecordInstance->{$this->getParentColumnName()})
318 return $result;
322 * Returns an array of parent Objects this is usefull to make breadcrum like stuctures
324 function &getParents()
326 $Ancestors =& $this->getAncestors();
327 return $Ancestors;
332 * Prunes a branch off of the tree, shifting all of the elements on the right
333 * back to the left so the counts still work.
335 function beforeDestroy(&$object)
337 if(!empty($object->__avoid_nested_set_before_destroy_recursion)){
338 return true;
340 if((empty($object->{$this->getRightColumnName()}) || empty($object->{$this->getLeftColumnName()})) || $object->nested_set->isUnknown()){
341 return true;
343 $dif = $object->{$this->getRightColumnName()} - $object->{$this->getLeftColumnName()} + 1;
345 $ObjectsToDelete = $object->nested_set->getAllChildren();
347 $object->transactionStart();
349 if(!empty($ObjectsToDelete)){
350 foreach (array_keys($ObjectsToDelete) as $k){
351 $Child =& $ObjectsToDelete[$k];
352 $Child->__avoid_nested_set_before_destroy_recursion = true;
353 if($Child->beforeDestroy()){
354 if($Child->notifyObservers('beforeDestroy') === false){
355 $Child->transactionFail();
357 }else{
358 $Child->transactionFail();
363 $object->deleteAll($this->getScopeCondition().
364 " AND ".$this->getLeftColumnName()." > ".$object->{$this->getLeftColumnName()}.
365 " AND ".$this->getRightColumnName()." < ".$object->{$this->getRightColumnName()});
367 $object->updateAll($this->getLeftColumnName()." = (".$this->getLeftColumnName()." - $dif)",
368 $this->getScopeCondition()." AND ".$this->getLeftColumnName()." >= ".$object->{$this->getRightColumnName()} );
370 $object->updateAll($this->getRightColumnName()." = (".$this->getRightColumnName()." - $dif )",
371 $this->getScopeCondition()." AND ".$this->getRightColumnName()." >= ".$object->{$this->getRightColumnName()});
374 if(!empty($ObjectsToDelete)){
375 foreach (array_keys($ObjectsToDelete) as $k){
376 $Child =& $ObjectsToDelete[$k];
377 $Child->__avoid_nested_set_before_destroy_recursion = true;
378 if(!$Child->afterDestroy() || $Child->notifyObservers('afterDestroy') === false){
379 $Child->transactionFail();
384 if($object->transactionHasFailed()){
385 $object->transactionComplete();
386 return false;
388 $object->transactionComplete();
390 return true;
394 * on creation, set automatically lft and rgt to the end of the tree
396 function beforeCreate(&$object)
398 $object->nested_set->_setLeftAndRightToTheEndOfTheTree();
399 return true;
402 function _setLeftAndRightToTheEndOfTheTree()
404 $left = $this->getLeftColumnName();
405 $right = $this->getRightColumnName();
407 $maxright = $this->_ActiveRecordInstance->maximum($right, array('conditions'=>$this->getScopeCondition()));
408 $maxright = empty($maxright) ? 0 : $maxright;
410 $this->_ActiveRecordInstance->set($left, $maxright+1);
411 $this->_ActiveRecordInstance->set($right, $maxright+2);
415 * Returns the single root
417 function getRoot()
419 return $this->_ActiveRecordInstance->find('first', array('conditions' => " ".$this->getScopeCondition()." AND ".$this->getParentColumnName()." IS NULL "));
423 * Returns roots when multiple roots (or virtual root, which is the same)
425 function getRoots()
427 return $this->_ActiveRecordInstance->find('all', array('conditions' => " ".$this->getScopeCondition()." AND ".$this->getParentColumnName()." IS NULL ",'order' => $this->getLeftColumnName()));
432 * Returns an array of all parents
434 function &getAncestors()
436 $Ancestors =& $this->_ActiveRecordInstance->find('all', array('conditions' => ' '.$this->getScopeCondition().' AND '.
437 $this->getLeftColumnName().' < '.$this->_ActiveRecordInstance->get($this->getLeftColumnName()).' AND '.
438 $this->getRightColumnName().' > '.$this->_ActiveRecordInstance->get($this->getRightColumnName())
439 ,'order' => $this->getLeftColumnName()));
440 return $Ancestors;
444 * Returns the array of all parents and self
446 function &getSelfAndAncestors()
448 if($result =& $this->getAncestors()){
449 array_push($result, $this->_ActiveRecordInstance);
450 }else{
451 $result = array(&$this->_ActiveRecordInstance);
453 return $result;
458 * Returns the array of all children of the parent, except self
460 function getSiblings($search_for_self = false)
462 return $this->_ActiveRecordInstance->find('all', array('conditions' => ' (('.$this->getScopeCondition().' AND '.
463 $this->getParentColumnName().' = '.$this->_ActiveRecordInstance->get($this->getParentColumnName()).' AND '.
464 $this->_ActiveRecordInstance->getPrimaryKey().' <> '.$this->_ActiveRecordInstance->getId().
465 ($search_for_self&&!$this->_ActiveRecordInstance->isNewRecord()?') OR ('.$this->_ActiveRecordInstance->getPrimaryKey().' = '.$this->_ActiveRecordInstance->quotedId().'))':'))')
466 ,'order' => $this->getLeftColumnName()));
470 * Returns the array of all children of the parent, included self
472 function getSelfAndSiblings()
474 $parent_id = $this->_ActiveRecordInstance->get($this->getParentColumnName());
475 if(empty($parent_id) || !$result = $this->getSiblings(true)){
476 $result = array($this->_ActiveRecordInstance);
478 return $result;
483 * Returns the level of this object in the tree
484 * root level is 0
486 function getLevel()
488 $parent_id = $this->_ActiveRecordInstance->get($this->getParentColumnName());
489 if(empty($parent_id)){
490 return 0;
492 return $this->_ActiveRecordInstance->count(' '.$this->getScopeCondition().' AND '.
493 $this->getLeftColumnName().' < '.$this->_ActiveRecordInstance->get($this->getLeftColumnName()).' AND '.
494 $this->getRightColumnName().' > '.$this->_ActiveRecordInstance->get($this->getRightColumnName()));
499 * Returns the number of all nested children of this object.
501 function countChildren()
503 $children_count = ($this->_ActiveRecordInstance->get($this->getRightColumnName()) - $this->_ActiveRecordInstance->get($this->getLeftColumnName()) - 1)/2;
504 return $children_count > 0 ? $children_count : 0;
509 * Returns a set of only this entry's immediate children
511 function getChildren()
513 return $this->_ActiveRecordInstance->find('all', array('conditions' => ' '.$this->getScopeCondition().' AND '.
514 $this->getParentColumnName().' = '.$this->_ActiveRecordInstance->getId()
515 ,'order' => $this->getLeftColumnName()));
519 * Returns a set of all of its children and nested children
521 function getAllChildren()
523 $args = func_get_args();
524 $excluded_ids = array();
525 if(!empty($args)){
526 $exclude = count($args) > 1 ? $args : (is_array($args[0]) ? $args[0] : (empty($args[0]) ? false : array($args[0])));
527 if(!empty($exclude)){
528 $parent_class_name = get_class($this->_ActiveRecordInstance);
529 foreach (array_keys($exclude) as $k){
530 $Item =& $exclude[$k];
531 if(is_a($Item,$parent_class_name)){
532 $ItemToExclude =& $Item;
533 }else{
534 $ItemToExclude =& $this->_ActiveRecordInstance->find($Item);
536 if($ItemSet = $ItemToExclude->nested_set->getFullSet()){
537 foreach (array_keys($ItemSet) as $l){
538 $excluded_ids[] = $ItemSet[$l]->getId();
542 $excluded_ids = array_unique(array_diff($excluded_ids,array('')));
545 return $this->_ActiveRecordInstance->find('all', array('conditions' => ' '.$this->getScopeCondition().' AND '.
546 (empty($excluded_ids) ? '' : ' id NOT IN ('.join(',',$excluded_ids).') AND ').
547 $this->getLeftColumnName().' > '.$this->_ActiveRecordInstance->get($this->getLeftColumnName()).' AND '.
548 $this->getRightColumnName().' < '.$this->_ActiveRecordInstance->get($this->getRightColumnName())
549 ,'order' => $this->getLeftColumnName()));
553 * Returns a set of itself and all of its nested children
555 function getFullSet($exclude = null)
557 if($this->_ActiveRecordInstance->isNewRecord() || $this->_ActiveRecordInstance->get($this->getRightColumnName()) - $this->_ActiveRecordInstance->get($this->getLeftColumnName()) == 1 ){
558 $result = array($this->_ActiveRecordInstance);
559 }else{
560 (array)$result = $this->getAllChildren($exclude);
561 array_unshift($result, $this->_ActiveRecordInstance);
563 return $result;
568 * Move the node to the left of another node
570 function moveToLeftOf($node)
572 return $this->moveTo($node, 'left');
576 * Move the node to the left of another node
578 function moveToRightOf($node)
580 return $this->moveTo($node, 'right');
584 * Move the node to the child of another node
586 function moveToChildOf($node)
588 return $this->moveTo($node, 'child');
591 function moveTo($target, $position)
593 if($this->_ActiveRecordInstance->isNewRecord()){
594 trigger_error(Ak::t('You cannot move a new node'), E_USER_ERROR);
596 $current_left = $this->_ActiveRecordInstance->get($this->getLeftColumnName());
597 $current_right = $this->_ActiveRecordInstance->get($this->getRightColumnName());
598 // $extent is the width of the tree self and children
599 $extent = $current_right - $current_left + 1;
601 // load object if node is not an object
602 if (is_numeric($target)){
603 $target =& $this->_ActiveRecordInstance->find($target);
605 if(!$target || !is_a($target, get_class($this->_ActiveRecordInstance))){
606 trigger_error(Ak::t('Invalid target'), E_USER_NOTICE);
607 return false;
610 $target_left = $target->get($this->getLeftColumnName());
611 $target_right = $target->get($this->getRightColumnName());
614 // detect impossible move
615 if ((($current_left <= $target_left) && ($target_left <= $current_right)) || (($current_left <= $target_right) && ($target_right <= $current_right))){
616 trigger_error(Ak::t('Impossible move, target node cannot be inside moved tree.'), E_USER_ERROR);
619 // compute new left/right for self
620 if ($position == 'child'){
621 if ($target_left < $current_left){
622 $new_left = $target_left + 1;
623 $new_right = $target_left + $extent;
624 }else{
625 $new_left = $target_left - $extent + 1;
626 $new_right = $target_left;
628 }elseif($position == 'left'){
629 if ($target_left < $current_left){
630 $new_left = $target_left;
631 $new_right = $target_left + $extent - 1;
632 }else{
633 $new_left = $target_left - $extent;
634 $new_right = $target_left - 1;
636 }elseif($position == 'right'){
637 if ($target_right < $current_right){
638 $new_left = $target_right + 1;
639 $new_right = $target_right + $extent;
640 }else{
641 $new_left = $target_right - $extent + 1;
642 $new_right = $target_right;
644 }else{
645 trigger_error(Ak::t("Position should be either left or right ('%position' received).",array('%position'=>$position)), E_USER_ERROR);
648 // boundaries of update action
649 $left_boundary = min($current_left, $new_left);
650 $right_boundary = max($current_right, $new_right);
652 // Shift value to move self to new $position
653 $shift = $new_left - $current_left;
655 // Shift value to move nodes inside boundaries but not under self_and_children
656 $updown = ($shift > 0) ? -$extent : $extent;
658 // change null to NULL for new parent
659 if($position == 'child'){
660 $new_parent = $target->getId();
661 }else{
662 $target_parent = $target->get($this->getParentColumnName());
663 $new_parent = empty($target_parent) ? 'NULL' : $target_parent;
666 $this->_ActiveRecordInstance->updateAll(
668 $this->getLeftColumnName().' = CASE '.
669 'WHEN '.$this->getLeftColumnName().' BETWEEN '.$current_left.' AND '.$current_right.' '.
670 'THEN '.$this->getLeftColumnName().' + '.$shift.' '.
671 'WHEN '.$this->getLeftColumnName().' BETWEEN '.$left_boundary.' AND '.$right_boundary.' '.
672 'THEN '.$this->getLeftColumnName().' + '.$updown.' '.
673 'ELSE '.$this->getLeftColumnName().' END, '.
675 $this->getRightColumnName().' = CASE '.
676 'WHEN '.$this->getRightColumnName().' BETWEEN '.$current_left.' AND '.$current_right.' '.
677 'THEN '.$this->getRightColumnName().' + '.$shift.' '.
678 'WHEN '.$this->getRightColumnName().' BETWEEN '.$left_boundary.' AND '.$right_boundary.' '.
679 'THEN '.$this->getRightColumnName().' + '.$updown.' '.
680 'ELSE '.$this->getRightColumnName().' END, '.
682 $this->getParentColumnName().' = CASE '.
683 'WHEN '.$this->_ActiveRecordInstance->getPrimaryKey().' = '.$this->_ActiveRecordInstance->getId().' '.
684 'THEN '.$new_parent.' '.
685 'ELSE '.$this->getParentColumnName().' END',
687 $this->getScopeCondition() );
688 $this->_ActiveRecordInstance->reload();
690 return true;