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 // +----------------------------------------------------------------------+
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
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.
49 * If my circles in circles description didn't make sense, check out this sweet
52 * ___________________________________________________________________
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
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.
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
)){
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
);
134 $this->observe(&$ActiveRecordInstance);
137 trigger_error(Ak
::t('You are trying to set an object that is not an active record.'), E_USER_ERROR
);
143 function reloadActiveRecordInstance(&$nodeInstance)
145 AK_PHP5 ?
null : $nodeInstance->nested_set
->_ensureIsActiveRecordInstance($nodeInstance);
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;
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);
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.
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
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
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
250 function addChild( &$child )
252 $self =& $this->_ActiveRecordInstance
;
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?
269 $self->transactionFail();
270 $self->transactionComplete();
274 $child->set($parent_column, $self->getId());
275 $child->set($left_column, 2);
276 $child->set($right_column, 3);
279 $self->transactionFail();
280 $self->transactionComplete();
283 $self->transactionComplete();
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" );
298 $this->reloadActiveRecordInstance($child);
299 $self->transactionComplete();
306 * Returns the parent Object
308 function &getParent()
310 if(!$this->isChild()){
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()})
322 * Returns an array of parent Objects this is usefull to make breadcrum like stuctures
324 function &getParents()
326 $Ancestors =& $this->getAncestors();
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
)){
340 if((empty($object->{$this->getRightColumnName()}) ||
empty($object->{$this->getLeftColumnName()})) ||
$object->nested_set
->isUnknown()){
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();
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();
388 $object->transactionComplete();
394 * on creation, set automatically lft and rgt to the end of the tree
396 function beforeCreate(&$object)
398 $object->nested_set
->_setLeftAndRightToTheEndOfTheTree();
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
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)
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()));
444 * Returns the array of all parents and self
446 function &getSelfAndAncestors()
448 if($result =& $this->getAncestors()){
449 array_push($result, $this->_ActiveRecordInstance
);
451 $result = array(&$this->_ActiveRecordInstance
);
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
);
483 * Returns the level of this object in the tree
488 $parent_id = $this->_ActiveRecordInstance
->get($this->getParentColumnName());
489 if(empty($parent_id)){
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();
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;
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
);
560 (array)$result = $this->getAllChildren($exclude);
561 array_unshift($result, $this->_ActiveRecordInstance
);
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
);
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;
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;
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;
641 $new_left = $target_right - $extent +
1;
642 $new_right = $target_right;
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();
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();