├── tests ├── fixture │ ├── model │ │ ├── ImageTag.php │ │ ├── Comment.php │ │ ├── Tag.php │ │ └── Image.php │ └── source │ │ ├── TagFixture.php │ │ ├── ImageTagFixture.php │ │ ├── ImageFixture.php │ │ └── CommentFixture.php ├── ci_bootstrap.php └── integration │ └── data │ └── behavior │ └── TreeTest.php ├── .travis.yml ├── composer.json ├── LICENSE.txt ├── README.md └── extensions └── data └── behavior └── Tree.php /tests/fixture/model/ImageTag.php: -------------------------------------------------------------------------------- 1 | ['via' => 'ImageTag']]; 19 | } -------------------------------------------------------------------------------- /tests/fixture/model/Image.php: -------------------------------------------------------------------------------- 1 | ['via' => 'ImageTag']]; 21 | 22 | } 23 | -------------------------------------------------------------------------------- /.travis.yml: -------------------------------------------------------------------------------- 1 | language: php 2 | 3 | php: 5.4 4 | 5 | before_script: 6 | - mkdir config 7 | - cp tests/ci_bootstrap.php config/bootstrap.php 8 | - mkdir ../libraries 9 | - git clone --branch=dev --depth=100 --quiet git://github.com/UnionOfRAD/lithium.git ../libraries/lithium 10 | - git clone --branch=master --depth=100 --quiet git://github.com/UnionOfRAD/li3_fixtures.git ../libraries/li3_fixtures 11 | - git clone --branch=master --depth=100 --quiet git://github.com/jails/li3_behaviors.git ../libraries/li3_behaviors 12 | - mysql -e 'create database li3tree_test;' 13 | 14 | script: ../libraries/lithium/console/li3 test --filters=Profiler tests/integration -------------------------------------------------------------------------------- /composer.json: -------------------------------------------------------------------------------- 1 | { 2 | "name": "jails/li3_tree", 3 | "type": "lithium-library", 4 | "description": "Model tree behavior for the Lithium PHP framework", 5 | "keywords": ["php", "model", "tree", "behavior", "lithium", "li3"], 6 | "homepage": "https://github.com/jails/li3_tree", 7 | "license": "BSD-3-Clause", 8 | "minimum-stability": "dev", 9 | "authors": [ 10 | { 11 | "name": "Simon JAILLET", 12 | "homepage": "https://github.com/jails" 13 | }, 14 | { 15 | "name": "Community", 16 | "homepage": "http://github.com/jails/li3_tree/graphs/contributors" 17 | } 18 | ], 19 | "require": { 20 | "php": ">=5.4", 21 | "composer/installers": "dev-master", 22 | "jails/li3_behaviors": "dev-master" 23 | } 24 | } -------------------------------------------------------------------------------- /tests/fixture/source/TagFixture.php: -------------------------------------------------------------------------------- 1 | ['type' => 'id'], 17 | 'name' => ['type' => 'string', 'length' => 50], 18 | 'author_id' => ['type' => 'integer', 'length' => 11] 19 | ]; 20 | 21 | protected $_records = [ 22 | ['id' => 1, 'name' => 'High Tech', 'author_id' => 6], 23 | ['id' => 3, 'name' => 'Sport', 'author_id' => 9], 24 | ['id' => 4, 'name' => 'Computer', 'author_id' => 6], 25 | ['id' => 5, 'name' => 'Art', 'author_id' => 2], 26 | ['id' => 6, 'name' => 'Science', 'author_id' => 1], 27 | ['id' => 7, 'name' => 'City', 'author_id' => 2] 28 | ]; 29 | } 30 | -------------------------------------------------------------------------------- /tests/fixture/source/ImageTagFixture.php: -------------------------------------------------------------------------------- 1 | ['type' => 'id'], 17 | 'image_id' => ['type' => 'integer', 'length' => 11], 18 | 'tag_id' => ['type' => 'integer', 'length' => 11] 19 | ]; 20 | 21 | protected $_records = [ 22 | ['id' => 1, 'image_id' => 1, 'tag_id' => 1], 23 | ['id' => 2, 'image_id' => 1, 'tag_id' => 4], 24 | ['id' => 3, 'image_id' => 2, 'tag_id' => 6], 25 | ['id' => 4, 'image_id' => 3, 'tag_id' => 7], 26 | ['id' => 5, 'image_id' => 4, 'tag_id' => 7], 27 | ['id' => 6, 'image_id' => 4, 'tag_id' => 4], 28 | ['id' => 7, 'image_id' => 4, 'tag_id' => 1] 29 | ]; 30 | } 31 | -------------------------------------------------------------------------------- /tests/fixture/source/ImageFixture.php: -------------------------------------------------------------------------------- 1 | ['type' => 'id'], 17 | 'gallery_id' => ['type' => 'integer', 'length' => 11], 18 | 'image' => ['type' => 'string', 'length' => 255], 19 | 'title' => ['type' => 'string', 'length' => 50] 20 | ]; 21 | 22 | protected $_records = [ 23 | ['id' => 1, 'gallery_id' => 1, 'image' => 'someimage.png', 'title' => 'Amiga 1200'], 24 | ['id' => 2, 'gallery_id' => 1, 'image' => 'image.jpg', 'title' => 'Srinivasa Ramanujan'], 25 | ['id' => 3, 'gallery_id' => 1, 'image' => 'photo.jpg', 'title' => 'Las Vegas'], 26 | ['id' => 4, 'gallery_id' => 2, 'image' => 'picture.jpg', 'title' => 'Silicon Valley'], 27 | ['id' => 5, 'gallery_id' => 2, 'image' => 'unknown.gif', 'title' => 'Unknown'] 28 | ]; 29 | } 30 | -------------------------------------------------------------------------------- /LICENSE.txt: -------------------------------------------------------------------------------- 1 | Copyright (c) 2012, Union of RAD http://union-of-rad.org 2 | All rights reserved. 3 | 4 | Redistribution and use in source and binary forms, with or without modification, 5 | are permitted provided that the following conditions are met: 6 | 7 | * Redistributions of source code must retain the above copyright notice, 8 | this list of conditions and the following disclaimer. 9 | * Redistributions in binary form must reproduce the above copyright notice, 10 | this list of conditions and the following disclaimer in the documentation 11 | and/or other materials provided with the distribution. 12 | * Neither the name of Lithium, Union of Rad, nor the names of its contributors 13 | may be used to endorse or promote products derived from this software 14 | without specific prior written permission. 15 | 16 | THIS SOFTWARE IS PROVIDED BY THE COPYRIGHT HOLDERS AND CONTRIBUTORS "AS IS" AND 17 | ANY EXPRESS OR IMPLIED WARRANTIES, INCLUDING, BUT NOT LIMITED TO, THE IMPLIED 18 | WARRANTIES OF MERCHANTABILITY AND FITNESS FOR A PARTICULAR PURPOSE ARE DISCLAIMED. 19 | IN NO EVENT SHALL THE COPYRIGHT HOLDER OR CONTRIBUTORS BE LIABLE FOR ANY DIRECT, 20 | INDIRECT, INCIDENTAL, SPECIAL, EXEMPLARY, OR CONSEQUENTIAL DAMAGES (INCLUDING, 21 | BUT NOT LIMITED TO, PROCUREMENT OF SUBSTITUTE GOODS OR SERVICES; LOSS OF USE, 22 | DATA, OR PROFITS; OR BUSINESS INTERRUPTION) HOWEVER CAUSED AND ON ANY THEORY 23 | OF LIABILITY, WHETHER IN CONTRACT, STRICT LIABILITY, OR TORT (INCLUDING 24 | NEGLIGENCE OR OTHERWISE) ARISING IN ANY WAY OUT OF THE USE OF THIS SOFTWARE, 25 | EVEN IF ADVISED OF THE POSSIBILITY OF SUCH DAMAGE. 26 | -------------------------------------------------------------------------------- /tests/ci_bootstrap.php: -------------------------------------------------------------------------------- 1 | true]); 31 | 32 | /** 33 | * Load test dependencies 34 | */ 35 | Libraries::add('li3_behaviors'); 36 | Libraries::add('li3_fixtures'); 37 | 38 | /** 39 | * Setup test database 40 | */ 41 | Connections::add('test', [ 42 | 'type' => 'database', 43 | 'adapter' => 'MySql', 44 | 'host' => 'localhost', 45 | 'login' => 'root', 46 | 'password' => '', 47 | 'database' => 'li3tree_test', 48 | 'encoding' => 'UTF-8' 49 | ]); 50 | 51 | ?> -------------------------------------------------------------------------------- /tests/fixture/source/CommentFixture.php: -------------------------------------------------------------------------------- 1 | ['type' => 'id'], 17 | 'image_id' => ['type' => 'integer'], 18 | 'body' => ['type' => 'text'], 19 | 'parent_id' => ['type' => 'integer'], 20 | 'lft' => ['type' => 'integer'], 21 | 'rght' => ['type' => 'integer'], 22 | 'published' => ['type' => 'string', 'length' => 1, 'default' => 'N'] 23 | ]; 24 | 25 | protected $_records = [ 26 | ['id' => 1, 'image_id' => 1, 'body' => 'Comment 1', 'parent_id' => null, 'lft' => 1, 'rght' => 8, 'published' => 'Y'], 27 | ['id' => 2, 'image_id' => 1, 'body' => 'Comment 1.1', 'parent_id' => 1, 'lft' => 2, 'rght' => 7, 'published' => 'Y'], 28 | ['id' => 3, 'image_id' => 1, 'body' => 'Comment 1.1.1', 'parent_id' => 2, 'lft' => 3, 'rght' => 4, 'published' => 'N'], 29 | ['id' => 4, 'image_id' => 1, 'body' => 'Comment 1.1.2', 'parent_id' => 2, 'lft' => 5, 'rght' => 6, 'published' => 'Y'], 30 | ['id' => 5, 'image_id' => 3, 'body' => 'Comment 2', 'parent_id' => null, 'lft' => 1, 'rght' => 6, 'published' => 'Y'], 31 | ['id' => 6, 'image_id' => 3, 'body' => 'Comment 2.1', 'parent_id' => 5, 'lft' => 2, 'rght' => 3, 'published' => 'Y'], 32 | ['id' => 7, 'image_id' => 3, 'body' => 'Comment 2.2', 'parent_id' => 5, 'lft' => 4, 'rght' => 5, 'published' => 'N'] 33 | ]; 34 | } 35 | 36 | ?> -------------------------------------------------------------------------------- /README.md: -------------------------------------------------------------------------------- 1 | # Tree behavior 2 | 3 | ## Requirements 4 | 5 | - **PHP 5.4** 6 | - This plugin needs [li3_behaviors](https://github.com/jails/li3_behaviors). 7 | - This plugin needs [li3_fixtures](https://github.com/UnionOfRAD/li3_fixtures) (only if you intend to run tests). 8 | 9 | ## Installation 10 | 11 | Checkout the code to either of your library directories: 12 | 13 | ``` 14 | cd libraries 15 | git clone git@github.com:jails/li3_tree 16 | ``` 17 | 18 | Include the library in your `/app/config/bootstrap/libraries.php` 19 | 20 | ``` 21 | Libraries::add('li3_tree'); 22 | ``` 23 | 24 | ## Presentation 25 | 26 | This behavior store hierarchical datas in a database table using the MPTT logic. 27 | 28 | ## Constraints 29 | 30 | To use the tree behavior, your table needs the following 3 extra fields: 31 | 32 | - The `'parent'` config field. By default the field must be named `parent_id` in the table. 33 | - The `'left'` config field. By default the field must be named `lft` in the table. 34 | - The `'right'` config field. By default the field must be named `rght` in the table. 35 | 36 | ## API 37 | 38 | Example of attaching the tree behavior to a model: 39 | 40 | ```php 41 | ['scope' => ['post_id']]]; 53 | } 54 | ?> 55 | ``` 56 | 57 | ### Options 58 | 59 | In order to store multiple trees in the same table you need to set the `'scope'` option. For example, if a `Post` hasMany `Comment` and a `Comment` belongsTo a `Post`. To allow multiple `Comment` trees in the same table, you must scope `Comment` by the foreign key `post_id`. This way all `Comment` trees will be independant. 60 | 61 | `'scope'` can be a full condition: 62 | 63 | ```php 64 | protected $_actsAs = ['Tree' => ['scope' => ['region' => 'head']]]; 65 | ``` 66 | 67 | Or a simple fieldname like a foreign key: 68 | 69 | ```php 70 | protected $_actsAs = ['Tree' => ['scope' => ['post_id']]]; 71 | ``` 72 | 73 | In this last case, the full condition will be populated from entity datas. This mean you can't do any CRUD action if the entity datas don't contain all necessary datas for perfoming a well scoped CRUD action. 74 | 75 | ### Example of use: 76 | ```php 77 | 1]); 80 | $root1->save(); 81 | 82 | $root2 = Comment::create(['post_id' => 2]); 83 | $root2->save(); 84 | 85 | $neighbor1 = Comment::create(['post_id' => 1]); 86 | $neighbor1->save(); 87 | 88 | $neighbor1->moveDown(); 89 | $root1->moveUp(); 90 | $neighbor1->move(0); 91 | 92 | $subelement1 = Comment::create(['post_id' => 1, 'parent_id' => $neighbor1->id]); 93 | $subelement1->save(); 94 | 95 | var_export($root1->childrens()); 96 | var_export($subelement1->path()); 97 | ?> 98 | ``` 99 | 100 | ## Greetings 101 | 102 | The li3 team, Vogan and all others which make that possible (I mean only because Chuck Norris agreed). 103 | 104 | ## Build status 105 | [![Build Status](https://secure.travis-ci.org/jails/li3_tree.png?branch=master)](http://travis-ci.org/jails/li3_tree) 106 | 107 | 108 | -------------------------------------------------------------------------------- /tests/integration/data/behavior/TreeTest.php: -------------------------------------------------------------------------------- 1 | 'li3_tree\tests\fixture\source\ImageFixture', 24 | 'comment' => 'li3_tree\tests\fixture\source\CommentFixture' 25 | ]; 26 | 27 | /** 28 | * Skip the test if no test database connection available. 29 | */ 30 | public function skip() { 31 | $dbConfig = Connections::get($this->_connection, ['config' => true]); 32 | $isAvailable = ( 33 | $dbConfig && 34 | Connections::get($this->_connection)->isConnected(['autoConnect' => true]) 35 | ); 36 | $this->skipIf(!$isAvailable, "No {$this->_connection} connection available."); 37 | 38 | $this->_db = Connections::get($this->_connection); 39 | 40 | $this->skipIf( 41 | !($this->_db instanceof Database), 42 | "The {$this->_connection} connection is not a relational database." 43 | ); 44 | } 45 | 46 | /** 47 | * Creating the test database 48 | * 49 | */ 50 | public function setUp() { 51 | Fixtures::config([ 52 | 'db' => [ 53 | 'adapter' => 'Connection', 54 | 'connection' => $this->_connection, 55 | 'fixtures' => $this->_fixtures 56 | ] 57 | ]); 58 | Comment::config(['meta' => ['connection' => $this->_connection]]); 59 | Image::config(['meta' => ['connection' => $this->_connection]]); 60 | } 61 | 62 | /** 63 | * Dropping the test database 64 | */ 65 | public function tearDown() { 66 | Comment::reset(); 67 | Image::reset(); 68 | Fixtures::clear('db'); 69 | } 70 | 71 | public function testInit() { 72 | $this->expectException("/`'model'` option needs to be defined/"); 73 | new Tree(); 74 | } 75 | 76 | public function testVerify() { 77 | Fixtures::save('db', ['comment']); 78 | $entities = Comment::find('all', ['order' => ['lft' => 'asc']]); 79 | Comment::actsAs('Tree', ['scope' => ['image_id']]); 80 | foreach ($entities as $entity) { 81 | $this->assertTrue($entity->verify() === true); 82 | } 83 | } 84 | 85 | public function testChildren() { 86 | Fixtures::save('db', ['comment']); 87 | $entities = Comment::find('all'); 88 | $datas = $entities->data(); 89 | $idField = Comment::key(); 90 | $cpt = 0; 91 | foreach ($entities as $entity) { 92 | $expected = []; 93 | foreach ($datas as $data) { 94 | if ($entity->$idField == $data['parent_id']) { 95 | $expected[$data[$idField]] = $data; 96 | } 97 | } 98 | $cpt++; 99 | $this->assertEqual($expected, $entity->childrens()->data()); 100 | } 101 | 102 | $this->assertEqual(7, $cpt); 103 | 104 | $cpt = 0; 105 | foreach ($entities as $entity) { 106 | $expected = []; 107 | foreach ($datas as $data) { 108 | if ($entity->lft < $data['lft'] && 109 | $entity->rght > $data['rght']) { 110 | $expected[$data['id']] = $data; 111 | } 112 | } 113 | $cpt++; 114 | $tmp = $entity->childrens(true)->data(); 115 | $result = []; 116 | foreach ($tmp as $value) { 117 | $result[$value['id']] = $value; 118 | } 119 | $this->assertEqual($expected, $result); 120 | } 121 | } 122 | 123 | public function testChildrenScope() { 124 | Fixtures::save('db', ['comment']); 125 | $entity = Comment::find('first'); 126 | $this->assertEqual(5, count($entity->childrens(true)->data())); 127 | 128 | Comment::actsAs('Tree', ['scope' => ['image_id']]); 129 | $this->assertEqual(3, count($entity->childrens(true)->data())); 130 | } 131 | 132 | public function testCountChildren() { 133 | Fixtures::save('db', ['comment']); 134 | $entity = Comment::find('first'); 135 | $this->assertEqual(3, $entity->childrens(true, 'count')); 136 | } 137 | 138 | public function testCountChildrenScope() { 139 | Fixtures::save('db', ['comment']); 140 | $entity = Comment::find('first'); 141 | 142 | Comment::actsAs('Tree', ['scope' => ['image_id']]); 143 | $this->assertEqual(3, $entity->childrens(true, 'count')); 144 | $this->assertEqual(1, $entity->childrens(false, 'count')); 145 | } 146 | 147 | public function testPath() { 148 | Fixtures::save('db', ['comment']); 149 | $entities = Comment::find('all', ['order' => ['lft' => 'asc']]); 150 | 151 | $expected = [ 152 | '1' => $entities[1]->data(), 153 | '2' => $entities[2]->data(), 154 | '4' => $entities[4]->data() 155 | ]; 156 | $this->assertEqual($expected, $entities[4]->path()->data()); 157 | 158 | } 159 | 160 | public function testPathScope() { 161 | Fixtures::save('db', ['comment']); 162 | $entities = Comment::find('all', ['order' => ['lft' => 'asc']]); 163 | 164 | Comment::actsAs('Tree', ['scope' => ['image_id']]); 165 | $expected = [ 166 | '1' => $entities[1]->data(), 167 | '2' => $entities[2]->data(), 168 | '4' => $entities[4]->data() 169 | ]; 170 | $this->assertEqual($expected, $entities[4]->path()->data()); 171 | } 172 | 173 | public function testMoveUpAndDown() { 174 | Fixtures::save('db', ['comment']); 175 | Comment::actsAs('Tree', ['scope' => ['image_id']]); 176 | 177 | $entities = Comment::find('all', ['order' => ['lft' => 'asc']]); 178 | $datas = $entities->data(); 179 | 180 | $node6 = $entities[4]->data(); 181 | $this->assertTrue($entities[4]->moveUp()); 182 | 183 | $expected = [ 184 | 'id' => 4, 185 | 'image_id' => 1, 186 | 'body' => 'Comment 1.1.2', 187 | 'parent_id' => 2, 188 | 'lft' => 3, 189 | 'rght' => 4, 190 | 'published' => 'Y' 191 | ]; 192 | 193 | $this->assertEqual($expected, $entities[4]->data()); 194 | 195 | $this->assertTrue($entities[4]->moveUp()); 196 | $this->assertEqual($expected, $entities[4]->data()); 197 | 198 | $this->assertTrue($entities[4]->moveDown()); 199 | $this->assertEqual($node6, $entities[4]->data()); 200 | 201 | $this->assertTrue($entities[4]->moveDown()); 202 | $this->assertEqual($node6, $entities[4]->data()); 203 | 204 | $entities = Comment::find('all', ['order' => ['lft' => 'asc']]); 205 | $this->assertEqual($datas, $entities->data()); 206 | } 207 | 208 | public function testMove() { 209 | Fixtures::save('db', ['comment']); 210 | Comment::actsAs('Tree', ['scope' => ['image_id']]); 211 | 212 | $entities = Comment::find('all', ['order' => ['lft' => 'asc']]); 213 | $datas = $entities->data(); 214 | 215 | $node6 = $entities[4]->data(); 216 | $this->assertTrue($entities[4]->move(0)); 217 | 218 | $expected = [ 219 | 'id' => 4, 220 | 'image_id' => 1, 221 | 'body' => 'Comment 1.1.2', 222 | 'parent_id' => 2, 223 | 'lft' => 3, 224 | 'rght' => 4, 225 | 'published' => 'Y' 226 | ]; 227 | 228 | $this->assertEqual($expected, $entities[4]->data()); 229 | 230 | $this->assertTrue($entities[4]->move(-1)); 231 | $this->assertEqual($expected, $entities[4]->data()); 232 | 233 | $this->assertTrue($entities[4]->move(1)); 234 | $this->assertEqual($node6, $entities[4]->data()); 235 | 236 | $this->assertTrue($entities[4]->move(2)); 237 | $this->assertEqual($node6, $entities[4]->data()); 238 | 239 | $entities = Comment::find('all', ['order' => ['lft' => 'asc']]); 240 | $this->assertEqual($datas, $entities->data()); 241 | } 242 | 243 | public function testReparent() { 244 | Fixtures::create('db', ['image']); 245 | Fixtures::save('db', ['comment']); 246 | Comment::actsAs('Tree', ['scope' => ['image_id']]); 247 | 248 | $entities = Comment::find('all', ['order' => ['lft' => 'asc']]); 249 | $datas = $entities->data(); 250 | 251 | $this->assertTrue($entities[4]->move(0, $entities[3])); 252 | 253 | $expected = [ 254 | 'id' => 4, 255 | 'image_id' => 1, 256 | 'body' => 'Comment 1.1.2', 257 | 'parent_id' => 3, 258 | 'lft' => 4, 259 | 'rght' => 5, 260 | 'published' => 'Y' 261 | ]; 262 | 263 | $this->assertEqual($expected, $entities[4]->data()); 264 | 265 | $entities = Comment::find('all', ['order' => ['lft' => 'asc']]); 266 | $expected = [ 267 | 'id' => 3, 268 | 'image_id' => 1, 269 | 'body' => 'Comment 1.1.1', 270 | 'parent_id' => 2, 271 | 'lft' => 3, 272 | 'rght' => 6, 273 | 'published' => 'N' 274 | ]; 275 | 276 | $this->assertEqual($expected, $entities[3]->data()); 277 | 278 | $entities = Comment::find('all', ['order' => ['id' => 'asc']]); 279 | $this->assertTrue($entities[1]->verify() === true); 280 | } 281 | 282 | public function testImpossibleReparent() { 283 | Fixtures::create('db', ['image']); 284 | Fixtures::save('db', ['comment']); 285 | Comment::actsAs('Tree', ['scope' => ['image_id']]); 286 | 287 | $entities = Comment::find('all', ['order' => ['lft' => 'asc']]); 288 | $this->assertFalse($entities[1]->move(0, $entities[4])); 289 | 290 | $entities = Comment::find('all', ['order' => ['id' => 'asc']]); 291 | $this->assertTrue($entities[1]->verify() === true); 292 | } 293 | 294 | public function testChangeScope() { 295 | Fixtures::create('db', ['image']); 296 | Fixtures::save('db', ['comment']); 297 | Comment::actsAs('Tree', ['scope' => ['image_id']]); 298 | 299 | $entities = Comment::find('all', ['order' => ['lft' => 'asc']]); 300 | 301 | $this->assertTrue($entities[3]->move(0, $entities[6])); 302 | 303 | $entities = Comment::find('all', ['order' => ['id' => 'asc']]); 304 | $this->assertTrue($entities[1]->verify() === true); 305 | $this->assertTrue($entities[3]->verify() === true); 306 | } 307 | 308 | public function testChangeScopeWithSubTree() { 309 | Fixtures::create('db', ['image']); 310 | Fixtures::save('db', ['comment']); 311 | Comment::actsAs('Tree', ['scope' => ['image_id']]); 312 | 313 | $entities = Comment::find('all', ['order' => ['lft' => 'asc']]); 314 | $this->assertTrue($entities[2]->move(0, $entities[6])); 315 | $entities = Comment::find('all', ['order' => ['id' => 'asc']]); 316 | $this->assertTrue($entities[1]->verify() === true); 317 | $this->assertTrue($entities[3]->verify() === true); 318 | } 319 | 320 | public function testDelete() { 321 | Fixtures::create('db', ['image']); 322 | Fixtures::save('db', ['comment']); 323 | Comment::actsAs('Tree', ['scope' => ['image_id']]); 324 | 325 | $entities = Comment::find('all', ['order' => ['lft' => 'asc']]); 326 | $this->assertTrue($entities[2]->delete()); 327 | $this->assertTrue($entities[6]->delete()); 328 | $entities = Comment::find('all', ['order' => ['id' => 'asc']]); 329 | $this->assertTrue($entities[1]->verify() === true); 330 | $this->assertTrue($entities[5]->verify() === true); 331 | } 332 | 333 | public function testCreate() { 334 | Fixtures::create('db', ['comment', 'image']); 335 | Comment::actsAs('Tree', ['scope' => ['image_id']]); 336 | 337 | $root1 = Comment::create(['image_id' => 1]); 338 | $root1->save(); 339 | $root2 = Comment::create(['image_id' => 2]); 340 | $root2->save(); 341 | $neighbor1 = Comment::create(['image_id' => 1]); 342 | $neighbor1->save(); 343 | 344 | $idField = Comment::key(); 345 | $subelement1 = Comment::create(['image_id' => 1, 'parent_id' => $neighbor1->$idField]); 346 | $subelement1->save(); 347 | 348 | $entities = Comment::find('all', ['order' => ['id' => 'asc']]); 349 | 350 | $expected = [ 351 | '1' => [ 352 | 'id' => '1', 'image_id' => '1', 'body' => null, 'parent_id' => null, 353 | 'lft' => '1', 'rght' => '2', 'published' => 'N' 354 | ], 355 | '2' => [ 356 | 'id' => '2', 'image_id' => '2', 'body' => null, 'parent_id' => null, 357 | 'lft' => '1', 'rght' => '2', 'published' => 'N' 358 | ], 359 | '3' => [ 360 | 'id' => '3', 'image_id' => '1', 'body' => null, 'parent_id' => null, 361 | 'lft' => '3', 'rght' => '6', 'published' => 'N' 362 | ], 363 | '4' => [ 364 | 'id' => '4', 'image_id' => '1', 'body' => null, 'parent_id' => '3', 365 | 'lft' => '4', 'rght' => '5', 'published' => 'N' 366 | ] 367 | ]; 368 | $this->assertEqual($expected, $entities->data()); 369 | } 370 | 371 | } -------------------------------------------------------------------------------- /extensions/data/behavior/Tree.php: -------------------------------------------------------------------------------- 1 | 'parent_id', 23 | 'left' => 'lft', 24 | 'right' => 'rght', 25 | 'recursive' => false, 26 | 'scope' => [] 27 | ]; 28 | 29 | /** 30 | * Constructor 31 | * 32 | * @param array $config The configuration array 33 | */ 34 | public function __construct($config = []){ 35 | parent::__construct($config + $this->_defaults); 36 | } 37 | 38 | /** 39 | * Initializer function called by the constructor unless the constructor `'init'` flag is set 40 | * to `false`. 41 | * 42 | * @see lithium\core\Object 43 | * @throws ConfigException 44 | */ 45 | public function _init() { 46 | parent::_init(); 47 | if (!$model = $this->_model) { 48 | throw new ConfigException("`'model'` option needs to be defined."); 49 | } 50 | $behavior = $this; 51 | $model::applyFilter('save', function($self, $params, $chain) use ($behavior) { 52 | if ($behavior->invokeMethod('_beforeSave', [$params])) { 53 | return $chain->next($self, $params, $chain); 54 | } 55 | }); 56 | 57 | $model::applyFilter('delete', function($self, $params, $chain) use ($behavior) { 58 | if ($behavior->invokeMethod('_beforeDelete', [$params])) { 59 | return $chain->next($self, $params, $chain); 60 | } 61 | }); 62 | } 63 | 64 | /** 65 | * Setting a scope to an entity node. 66 | * 67 | * @param object $entity 68 | * @return array The scope values 69 | * @throws UnexpectedValueException 70 | */ 71 | protected function _scope($entity) { 72 | $scope = []; 73 | foreach ($this->_config['scope'] as $key => $value) { 74 | if (is_numeric($key)) { 75 | if (isset($entity, $value)) { 76 | $scope[$value] = $entity->$value; 77 | } else { 78 | $message = "The `{$value}` scope in not present in the entity."; 79 | throw new UnexpectedValueException($message); 80 | } 81 | } else { 82 | $scope[$key] = $value; 83 | } 84 | } 85 | return $scope; 86 | } 87 | 88 | /** 89 | * Returns all childrens of given element (including subchildren if `$recursive` is set 90 | * to true or recursive is configured true) 91 | * 92 | * @param object $entity The entity to fetch the children of 93 | * @param Boolean $recursive Overrides configured recursive param for this method 94 | */ 95 | public function childrens($entity, $rec = null, $mode = 'all') { 96 | extract($this->_config); 97 | 98 | $recursive = $rec ? : $recursive; 99 | 100 | if ($recursive) { 101 | if ($mode === 'count') { 102 | return ($entity->$right - $entity->$left - 1) / 2; 103 | } else { 104 | return $model::find($mode, [ 105 | 'conditions' => [ 106 | $left => ['>' => $entity->$left], 107 | $right => ['<' => $entity->$right] 108 | ] + $this->_scope($entity), 109 | 'order' => [$left => 'asc']] 110 | ); 111 | } 112 | } else { 113 | $id = $entity->{$model::key()}; 114 | return $model::find($mode, [ 115 | 'conditions' => [$parent => $id] + $this->_scope($entity), 116 | 'order' => [$left => 'asc']] 117 | ); 118 | } 119 | } 120 | 121 | /** 122 | * Get path 123 | * 124 | * returns an array containing all elements from the tree root node to the node with given 125 | * an entity node (including this entity node) which have a parent/child relationship 126 | * 127 | * @param object $entity 128 | */ 129 | public function path($entity) { 130 | extract($this->_config); 131 | 132 | $data = []; 133 | while ($entity->data($parent) !== null) { 134 | $data[] = $entity; 135 | $entity = $this->_getById($entity->$parent); 136 | } 137 | $data[] = $entity; 138 | $data = array_reverse($data); 139 | $model = $entity->model(); 140 | return $model::create($data, [ 141 | 'exists' => true, 142 | 'class' => 'set' 143 | ]); 144 | } 145 | 146 | /** 147 | * Move 148 | * 149 | * performs move operations of an entity in tree 150 | * 151 | * @param object $entity the entity node to move 152 | * @param integer $newPosition Position new position of node in same level, starting with 0 153 | * @param object $newParent The new parent entity node 154 | */ 155 | public function move($entity, $newPosition, $newParent = null) { 156 | extract($this->_config); 157 | 158 | if ($newParent !== null) { 159 | if($this->_scope($entity) !== ($parentScope = $this->_scope($newParent))) { 160 | $entity->set($parentScope); 161 | } elseif ($newParent->$left > $entity->$left && $newParent->$right < $entity->$right) { 162 | return false; 163 | } 164 | $parentId = $newParent->data($model::key()); 165 | $entity->set([$parent => $parentId]); 166 | $entity->save(); 167 | $parentNode = $newParent; 168 | } else { 169 | $newParent = $this->_getById($entity->$parent); 170 | } 171 | 172 | $childrenCount = $newParent->childrens(false, 'count'); 173 | $position = $this->_getPosition($entity, $childrenCount); 174 | if ($position !== false) { 175 | $count = abs($newPosition - $position); 176 | 177 | for ($i = 0; $i < $count; $i++) { 178 | if ($position < $newPosition) { 179 | $entity->moveDown(); 180 | } else { 181 | $entity->moveUp(); 182 | } 183 | } 184 | } 185 | return true; 186 | } 187 | 188 | /** 189 | * Before save 190 | * 191 | * this method is called befor each save 192 | * 193 | * @param array $params 194 | */ 195 | protected function _beforeSave($params) { 196 | extract($this->_config); 197 | $entity = $params['entity']; 198 | 199 | if (!$entity->data($model::key())) { 200 | if ($entity->$parent) { 201 | $this->_insertParent($entity); 202 | } else { 203 | $max = $this->_getMax($entity); 204 | $entity->set([ 205 | $left => $max + 1, 206 | $right => $max + 2 207 | ]); 208 | } 209 | } elseif (isset($entity->$parent)) { 210 | if ($entity->$parent === $entity->data($model::key())) { 211 | return false; 212 | } 213 | $oldNode = $this->_getById($entity->data($model::key())); 214 | if ($oldNode->$parent === $entity->$parent) { 215 | return true; 216 | } 217 | if (($newScope = $this->_scope($entity)) !== ($oldScope = $this->_scope($oldNode))) { 218 | $this->_updateScope($entity, $oldScope, $newScope); 219 | return true; 220 | } 221 | $this->_updateNode($entity); 222 | } 223 | return true; 224 | } 225 | 226 | /** 227 | * Before delete 228 | * 229 | * this method is called befor each save 230 | * 231 | * @param array $params 232 | */ 233 | protected function _beforeDelete($params) { 234 | return $this->_deleteFromTree($params['entity']); 235 | } 236 | 237 | /** 238 | * Insert a parent 239 | * 240 | * inserts a node at given last position of parent set in $entity 241 | * 242 | * @param object $entity 243 | */ 244 | protected function _insertParent($entity) { 245 | extract($this->_config); 246 | $parent = $this->_getById($entity->$parent); 247 | if ($parent) { 248 | $r = $parent->$right; 249 | $this->_update($r, '+', 2, $this->_scope($entity)); 250 | $entity->set([ 251 | $left => $r, 252 | $right => $r + 1 253 | ]); 254 | } 255 | } 256 | 257 | /** 258 | * Update a node (when parent is changed) 259 | * 260 | * all the "move an element with all its children" magic happens here! 261 | * first we calculate movements (shiftX, shiftY), afterwards shifting of ranges is done, 262 | * where rangeX is is the range of the element to move and rangeY the area between rangeX 263 | * and the new position of rangeX. 264 | * to avoid double shifting of already shifted data rangex first is shifted in area < 0 265 | * (which is always empty), after correcting rangeY's left and rights we move it to its 266 | * designated position. 267 | * 268 | * @param object $entity updated tree element 269 | */ 270 | protected function _updateNode($entity) { 271 | extract($this->_config); 272 | 273 | $span = $entity->$right - $entity->$left; 274 | $spanToZero = $entity->$right; 275 | 276 | $rangeX = ['floor' => $entity->$left, 'ceiling' => $entity->$right]; 277 | $shiftY = $span + 1; 278 | 279 | if ($entity->$parent !== null) { 280 | $newParent = $this->_getById($entity->$parent); 281 | if ($newParent) { 282 | $boundary = $newParent->$right; 283 | } else { 284 | throw new UnexpectedValueException("The `{$parent}` with id `{$entity->$parent}` doesn't exists."); 285 | } 286 | } else { 287 | $boundary = $this->_getMax($entity) + 1; 288 | } 289 | $this->_updateBetween($rangeX, '-', $spanToZero, $this->_scope($entity)); 290 | 291 | if ($entity->$right < $boundary) { 292 | $rangeY = ['floor' => $entity->$right + 1, 'ceiling' => $boundary - 1]; 293 | $this->_updateBetween($rangeY, '-', $shiftY, $this->_scope($entity)); 294 | $shiftX = $boundary - $entity->$right - 1; 295 | } else { 296 | $rangeY = ['floor' => $boundary, 'ceiling' => $entity->$left - 1]; 297 | $this->_updateBetween($rangeY, '+', $shiftY, $this->_scope($entity)); 298 | $shiftX = ($boundary - 1) - $entity->$left + 1; 299 | } 300 | $this->_updateBetween([ 301 | 'floor' => (0 - $span), 'ceiling' => 0 302 | ], '+', $spanToZero + $shiftX, $this->_scope($entity)); 303 | $entity->set([$left => $entity->$left + $shiftX, $right => $entity->$right + $shiftX]); 304 | } 305 | 306 | /** 307 | * Update a node (when scope has changed) 308 | * 309 | * all the "move an element with all its children" magic happens here! 310 | * 311 | * @param object $entity Updated tree element 312 | * @param array $oldScope Old scope data 313 | * @param array $newScope New scope data 314 | */ 315 | protected function _updateScope($entity, $oldScope, $newScope) { 316 | extract($this->_config); 317 | 318 | $span = $entity->$right - $entity->$left; 319 | $spanToZero = $entity->$right; 320 | 321 | $rangeX = ['floor' => $entity->$left, 'ceiling' => $entity->$right]; 322 | 323 | $this->_updateBetween($rangeX, '-', $spanToZero, $oldScope, $newScope); 324 | $this->_update($entity->$right, '-', $span + 1, $oldScope); 325 | 326 | $newParent = $this->_getById($entity->$parent); 327 | $r = $newParent->$right; 328 | $this->_update($r, '+', $span + 1, $newScope); 329 | $this->_updateBetween([ 330 | 'floor' => (0 - $span), 'ceiling' => 0 331 | ], '+', $span + $r, $newScope); 332 | $entity->set([$left => $r, $right => $span + $r]); 333 | } 334 | 335 | /** 336 | * Delete from tree 337 | * 338 | * deletes a node (and its children) from the tree 339 | * 340 | * @param object $entity updated tree element 341 | */ 342 | protected function _deleteFromTree($entity) { 343 | extract($this->_config); 344 | 345 | $span = 1; 346 | if ($entity->$right - $entity->$left !== 1) { 347 | $span = $entity->$right - $entity->$left; 348 | $model::remove([$parent => $entity->data($model::key())]); 349 | } 350 | $this->_update($entity->$right, '-', $span + 1, $this->_scope($entity)); 351 | return true; 352 | } 353 | 354 | /** 355 | * Get by id 356 | * 357 | * returns the element with given id 358 | * 359 | * @param integer $id the id to fetch from db 360 | */ 361 | protected function _getById($id) { 362 | $model = $this->_config['model']; 363 | return $model::find('first', ['conditions' => [$model::key() => $id]]); 364 | } 365 | 366 | /** 367 | * Update node indices 368 | * 369 | * Updates the Indices in greater than $rght with given value. 370 | * 371 | * @param integer $rght the right index border to start indexing 372 | * @param string $dir Direction +/- (defaults to +) 373 | * @param integer $span value to be added/subtracted (defaults to 2) 374 | * @param array $scp The scope to apply updates on 375 | */ 376 | protected function _update($rght, $dir = '+', $span = 2, $scp = []) { 377 | extract($this->_config); 378 | 379 | $model::update([$right => (object) ($right . $dir . $span)], [ 380 | $right => ['>=' => $rght] 381 | ] + $scp); 382 | 383 | $model::update([$left => (object) ($left . $dir . $span)], [ 384 | $left => ['>' => $rght] 385 | ] + $scp); 386 | } 387 | 388 | /** 389 | * Update node indices between 390 | * 391 | * Updates the Indices in given range with given value. 392 | * 393 | * @param array $range the range to be updated 394 | * @param string $dir Direction +/- (defaults to +) 395 | * @param integer $span Value to be added/subtracted (defaults to 2) 396 | * @param array $scp The scope to apply updates on 397 | * @param array $data Additionnal scope datas (optionnal) 398 | */ 399 | protected function _updateBetween($range, $dir = '+', $span = 2, $scp = [], $data = []) { 400 | extract($this->_config); 401 | 402 | $model::update([$right => (object) ($right . $dir . $span)], [ 403 | $right => [ 404 | '>=' => $range['floor'], 405 | '<=' => $range['ceiling'] 406 | ]] + $scp); 407 | 408 | $model::update([$left => (object) ($left . $dir . $span)] + $data, [ 409 | $left => [ 410 | '>=' => $range['floor'], 411 | '<=' => $range['ceiling'] 412 | ]] + $scp); 413 | } 414 | 415 | /** 416 | * Moves an element down in order 417 | * 418 | * @param object $entity The Entity to move down 419 | */ 420 | public function moveDown($entity) { 421 | extract($this->_config); 422 | $next = $model::find('first', [ 423 | 'conditions' => [ 424 | $parent => $entity->$parent, 425 | $left => $entity->$right + 1 426 | ]]); 427 | 428 | if ($next !== null) { 429 | $spanToZero = $entity->$right; 430 | $rangeX = ['floor' => $entity->$left, 'ceiling' => $entity->$right]; 431 | $shiftX = ($next->$right - $next->$left) + 1; 432 | $rangeY = ['floor' => $next->$left, 'ceiling' => $next->$right]; 433 | $shiftY = ($entity->$right - $entity->$left) + 1; 434 | 435 | $this->_updateBetween($rangeX, '-', $spanToZero, $this->_scope($entity)); 436 | $this->_updateBetween($rangeY, '-', $shiftY, $this->_scope($entity)); 437 | $this->_updateBetween([ 438 | 'floor' => (0 - $shiftY), 'ceiling' => 0 439 | ], '+', $spanToZero + $shiftX, $this->_scope($entity)); 440 | 441 | $entity->set([ 442 | $left => $entity->$left + $shiftX, $right => $entity->$right + $shiftX 443 | ]); 444 | } 445 | return true; 446 | } 447 | 448 | /** 449 | * Moves an element up in order 450 | * 451 | * @param object $entity The Entity to move up 452 | */ 453 | public function moveUp($entity) { 454 | extract($this->_config); 455 | $prev = $model::find('first', [ 456 | 'conditions' => [ 457 | $parent => $entity->$parent, 458 | $right => $entity->$left - 1 459 | ] 460 | ]); 461 | if (!$prev) { 462 | return true; 463 | } 464 | $spanToZero = $entity->$right; 465 | $rangeX = ['floor' => $entity->$left, 'ceiling' => $entity->$right]; 466 | $shiftX = ($prev->$right - $prev->$left) + 1; 467 | $rangeY = ['floor' => $prev->$left, 'ceiling' => $prev->$right]; 468 | $shiftY = ($entity->$right - $entity->$left) + 1; 469 | 470 | $this->_updateBetween($rangeX, '-', $spanToZero, $this->_scope($entity)); 471 | $this->_updateBetween($rangeY, '+', $shiftY, $this->_scope($entity)); 472 | $this->_updateBetween([ 473 | 'floor' => (0 - $shiftY), 'ceiling' => 0 474 | ], '+', $spanToZero - $shiftX, $this->_scope($entity)); 475 | 476 | $entity->set([ 477 | $left => $entity->$left - $shiftX, $right => $entity->$right - $shiftX 478 | ]); 479 | return true; 480 | } 481 | 482 | /** 483 | * Get max 484 | * 485 | * @param object $entity An `Entity` object 486 | * @return The highest 'right' 487 | */ 488 | protected function _getMax($entity) { 489 | extract($this->_config); 490 | 491 | $node = $model::find('first', [ 492 | 'conditions' => $this->_scope($entity), 493 | 'order' => [$right => 'desc'] 494 | ]); 495 | if ($node) { 496 | return $node->$right; 497 | } 498 | return 0; 499 | } 500 | 501 | /** 502 | * Returns the current position number of an element at the same level, 503 | * where 0 is first position 504 | * 505 | * @param object $entity the entity node to get the position from. 506 | * @param integer $childrenCount number of children of entity's parent, 507 | * performance parameter to avoid double select. 508 | */ 509 | protected function _getPosition($entity, $childrenCount = false) { 510 | extract($this->_config); 511 | 512 | $parent = $this->_getById($entity->$parent); 513 | 514 | if ($entity->$left === ($parent->$left + 1)) { 515 | return 0; 516 | } 517 | 518 | if (($entity->$right + 1) === $parent->$right) { 519 | if ($childrenCount === false) { 520 | $childrenCount = $parent->childrens(false, 'count'); 521 | } 522 | return $childrenCount - 1; 523 | } 524 | 525 | $count = 0; 526 | $children = $parent->childrens(false); 527 | 528 | $id = $entity->data($model::key()); 529 | foreach ($children as $child) { 530 | if ($child->data($model::key()) === $id) { 531 | return $count; 532 | } 533 | $count++; 534 | } 535 | 536 | return false; 537 | } 538 | 539 | /** 540 | * Check if the current tree is valid. 541 | * 542 | * Returns true if the tree is valid otherwise an array of (type, incorrect left/right index, 543 | * message) 544 | * 545 | * @param object $entity the entity node to get the position from. 546 | * @return mixed true if the tree is valid or empty, otherwise an array of (error type [node, 547 | * boundary], [incorrect left/right boundary,node id], message) 548 | */ 549 | public function verify($entity) { 550 | extract($this->_config); 551 | 552 | $count = $model::find('count', [ 553 | 'conditions' => [ 554 | $left => ['>' => $entity->$left], 555 | $right => ['<' => $entity->$right] 556 | ] + $this->_scope($entity) 557 | ]); 558 | if (!$count) { 559 | return true; 560 | } 561 | $min = $entity->$left; 562 | $edge = $entity->$right; 563 | 564 | if ($entity->$left >= $entity->$right) { 565 | $id = $entity->data($model::key()); 566 | $errors[] = ['root node', "`{$id}`", 'has left greater than right.']; 567 | } 568 | 569 | $errors = []; 570 | 571 | for ($i = $min; $i <= $edge; $i++) { 572 | $count = $model::find('count', [ 573 | 'conditions' => [ 574 | 'or' => [$left => $i, $right => $i] 575 | ] + $this->_scope($entity) 576 | ]); 577 | 578 | if ($count !== 1) { 579 | if ($count === 0) { 580 | $errors[] = ['node boundary', "`{$i}`", 'missing']; 581 | } else { 582 | $errors[] = ['node boundary', "`{$i}`", 'duplicate']; 583 | } 584 | } 585 | } 586 | 587 | $node = $model::find('first', [ 588 | 'conditions' => [ 589 | $right => ['<' => $left] 590 | ] + $this->_scope($entity) 591 | ]); 592 | 593 | if ($node) { 594 | $id = $node->data($model::key()); 595 | $errors[] = ['node id', "`{$id}`", 'has left greater or equal to right.']; 596 | } 597 | 598 | $model::bind('belongsTo', 'Verify', [ 599 | 'to' => $model, 600 | 'key' => $parent 601 | ]); 602 | 603 | $results = $model::find('all', [ 604 | 'conditions' => $this->_scope($entity), 605 | 'with' => ['Verify'] 606 | ]); 607 | 608 | $id = $model::key(); 609 | foreach ($results as $key => $instance) { 610 | if (is_null($instance->$left) || is_null($instance->$right)) { 611 | $errors[] = ['node', $instance->$id, 612 | 'has invalid left or right values']; 613 | } elseif ($instance->$left === $instance->$right) { 614 | $errors[] = ['node', $instance->$id, 615 | 'left and right values identical']; 616 | } elseif ($instance->$parent) { 617 | if (!isset($instance->verify->$id) || !$instance->verify->$id) { 618 | $errors[] = ['node', $instance->$id, 619 | 'The parent node ' . $instance->$parent . ' doesn\'t exist']; 620 | } elseif ($instance->$left < $instance->verify->$left) { 621 | $errors[] = ['node', $instance->$id, 622 | 'left less than parent (node ' . $instance->verify->$id . ').']; 623 | } elseif ($instance->$right > $instance->verify->$right) { 624 | $errors[] = ['node', $instance->$id, 625 | 'right greater than parent (node ' . $instance->verify->$id . ').']; 626 | } 627 | } elseif ($model::find('count', [ 628 | 'conditions' => [ 629 | $left => ['<' => $instance->$left], 630 | $right => ['>' => $instance->$right] 631 | ] + $this->_scope($entity) 632 | ])) { 633 | $errors[] = ['node', $instance->$id, 'the parent field is blank, but has a parent']; 634 | } 635 | } 636 | 637 | if ($errors) { 638 | return $errors; 639 | } 640 | return true; 641 | } 642 | 643 | } 644 | 645 | ?> --------------------------------------------------------------------------------