1
|
|
|
<?php |
2
|
|
|
/* |
3
|
|
|
* This file is part of Yolk - Gamer Network's PHP Framework. |
4
|
|
|
* |
5
|
|
|
* Copyright (c) 2013 Gamer Network Ltd. |
6
|
|
|
* |
7
|
|
|
* Distributed under the MIT License, a copy of which is available in the |
8
|
|
|
* LICENSE file that was bundled with this package, or online at: |
9
|
|
|
* https://github.com/gamernetwork/yolk |
10
|
|
|
*/ |
11
|
|
|
|
12
|
|
|
namespace yolk\database\support; |
13
|
|
|
|
14
|
|
|
use yolk\contracts\database\DatabaseConnection; |
15
|
|
|
use yolk\contracts\support\Tree; |
16
|
|
|
|
17
|
|
|
// only manipulates tree fields (lft, rgt), doesn't insert or delete database records |
18
|
|
|
class DatabaseTree implements Tree { |
19
|
|
|
|
20
|
|
|
protected $db; |
21
|
|
|
|
22
|
|
|
protected $table_name; |
23
|
|
|
protected $name_field; |
24
|
|
|
|
25
|
|
|
public function __construct( DatabaseConnection $db, $table_name, $name_field = 'name' ) { |
26
|
|
|
$this->db = $db; |
27
|
|
|
$this->table_name = $table_name; |
28
|
|
|
$this->name_field = $name_field; |
29
|
|
|
} |
30
|
|
|
|
31
|
|
|
public function countAncestors( $id ) { |
32
|
|
|
|
33
|
|
|
$node = $this->findNodeById($id); |
34
|
|
|
|
35
|
|
|
return $node ? (int) $this->db->getOne( |
36
|
|
|
"SELECT COUNT(*) |
37
|
|
|
FROM {$this->table_name} |
38
|
|
|
WHERE lft < :lft |
39
|
|
|
AND rgt > :rgt", |
40
|
|
|
$node |
41
|
|
|
) : 0; |
42
|
|
|
|
43
|
|
|
} |
44
|
|
|
|
45
|
|
|
public function getAncestors( $id ) { |
46
|
|
|
|
47
|
|
|
$node = $this->findNodeById($id); |
48
|
|
|
|
49
|
|
|
return $node ? $this->db->getAssoc( |
50
|
|
|
"SELECT id, {$this->name_field} |
51
|
|
|
FROM {$this->table_name} |
52
|
|
|
WHERE lft < :lft |
53
|
|
|
AND rgt > :rgt |
54
|
|
|
ORDER BY lft ASC", |
55
|
|
|
$node |
56
|
|
|
) : array(); |
57
|
|
|
|
58
|
|
|
} |
59
|
|
|
|
60
|
|
|
public function countSiblings( $id ) { |
61
|
|
|
return (int) $this->db->getOne("SELECT COUNT(*) FROM {$this->table_name} WHERE parent_id = (SELECT parent_id FROM {$this->table_name} WHERE id = ?)", $id) - 1; |
62
|
|
|
} |
63
|
|
|
|
64
|
|
|
public function getSiblings( $id ) { |
65
|
|
|
$siblings = $this->db->getAssoc("SELECT id, {$this->name_field} FROM {$this->table_name} WHERE parent_id = (SELECT parent_id FROM {$this->table_name} WHERE id = ?)", $id); |
66
|
|
|
asort($siblings); |
67
|
|
|
return $siblings; |
68
|
|
|
} |
69
|
|
|
|
70
|
|
|
public function countChildren( $id ) { |
71
|
|
|
return (int) $this->db->getOne("SELECT COUNT(*) FROM {$this->table_name} WHERE parent_id = ", $id); |
72
|
|
|
} |
73
|
|
|
|
74
|
|
|
public function getChildren( $id ) { |
75
|
|
|
$children = $this->db->getAssoc("SELECT id, {$this->name_field} FROM {$this->table_name} WHERE parent_id = ?", $id); |
76
|
|
|
asort($children); |
77
|
|
|
return $children; |
78
|
|
|
} |
79
|
|
|
|
80
|
|
|
public function countDescendants( $id ) { |
81
|
|
|
return (int) $this->db->getOne("SELECT (rgt - lft - 1) / 2 FROM {$this->table_name} WHERE id = ?", $id); |
82
|
|
|
} |
83
|
|
|
|
84
|
|
|
public function getDescendants( $id, $absolute_depth = false ) { |
85
|
|
|
|
86
|
|
|
$node = $this->findNodeById($id); |
87
|
|
|
|
88
|
|
|
if( !$node ) |
|
|
|
|
89
|
|
|
return array(); |
90
|
|
|
|
91
|
|
|
$descendants = $this->db->getAssoc( |
92
|
|
|
"SELECT id, |
93
|
|
|
{$this->name_field}, |
94
|
|
|
lft, |
95
|
|
|
rgt |
96
|
|
|
FROM {$this->table_name} |
97
|
|
|
WHERE lft BETWEEN :lft + 1 AND :rgt - 1 |
98
|
|
|
ORDER BY lft ASC", |
99
|
|
|
$node |
100
|
|
|
); |
101
|
|
|
|
102
|
|
|
$offset = $absolute_depth ? $this->countAncestors($id) : 0; |
103
|
|
|
|
104
|
|
|
$stack = array(); |
105
|
|
|
foreach( $descendants as &$child ) { |
106
|
|
|
|
107
|
|
|
$child['lft'] = (int) $child['lft']; |
108
|
|
|
$child['rgt'] = (int) $child['rgt']; |
109
|
|
|
|
110
|
|
|
// if current left > right on top of stack we've gone down a level so pop the stack |
111
|
|
|
while( $stack && $child['lft'] > $stack[count($stack) - 1] ) |
|
|
|
|
112
|
|
|
array_pop($stack); |
113
|
|
|
|
114
|
|
|
$child['depth'] = $offset + count($stack) + 1; |
115
|
|
|
|
116
|
|
|
// node has children so push current right value to stack |
117
|
|
|
if( $child['rgt'] - $child['lft'] > 1 ) |
118
|
|
|
$stack[] = $child['rgt']; |
119
|
|
|
|
120
|
|
|
unset($child['lft']); |
121
|
|
|
unset($child['rgt']); |
122
|
|
|
|
123
|
|
|
} |
124
|
|
|
|
125
|
|
|
return $descendants; |
126
|
|
|
|
127
|
|
|
} |
128
|
|
|
|
129
|
|
|
public function insertNode( $id, $parent_id = 0 ) { |
130
|
|
|
|
131
|
|
|
// determine if a database transaction is currently active |
132
|
|
|
// if not we'll start one |
133
|
|
|
$transaction = $this->db->inTransaction(); |
134
|
|
|
|
135
|
|
|
try { |
136
|
|
|
|
137
|
|
|
if( !$transaction ) |
138
|
|
|
$this->db->begin(); |
139
|
|
|
|
140
|
|
|
if( $parent_id ) { |
141
|
|
|
|
142
|
|
|
$parent = $this->findNodeById($parent_id); |
143
|
|
|
|
144
|
|
|
// shift everything up to make room for the new node |
145
|
|
|
$this->db->query("UPDATE {$this->table_name} SET rgt = rgt + 2 WHERE rgt >= ?", $parent['rgt']); |
146
|
|
|
$this->db->query("UPDATE {$this->table_name} SET lft = lft + 2 WHERE lft >= ?", $parent['rgt']); |
147
|
|
|
|
148
|
|
|
// single query - http://www.slideshare.net/billkarwin/models-for-hierarchical-data (slide 36) |
149
|
|
|
/*update table set |
150
|
|
|
lft = case when left >= parent_rgt then lft + 2 else lft end |
151
|
|
|
rgt = rgt + 2 |
152
|
|
|
where rgt > parent_lft*/ |
153
|
|
|
|
154
|
|
|
} |
155
|
|
|
else { |
156
|
|
|
$parent = array( |
157
|
|
|
'rgt' => $this->db->getOne("SELECT MAX(rgt) + 1 FROM {$this->table_name}") |
158
|
|
|
); |
159
|
|
|
} |
160
|
|
|
|
161
|
|
|
$this->db->execute( |
162
|
|
|
"UPDATE {$this->table_name} SET lft = ?, rgt = ? WHERE id = ?", |
163
|
|
|
array($parent['rgt'], $parent['rgt'] + 1, $id) |
164
|
|
|
); |
165
|
|
|
|
166
|
|
|
if( !$transaction ) |
167
|
|
|
$this->db->commit(); |
168
|
|
|
|
169
|
|
|
} |
170
|
|
|
catch( \Exception $e ) { |
171
|
|
|
if( !$transaction ) |
172
|
|
|
$this->db->rollback(); |
173
|
|
|
throw $e; |
174
|
|
|
} |
175
|
|
|
|
176
|
|
|
return $this; |
177
|
|
|
|
178
|
|
|
} |
179
|
|
|
|
180
|
|
|
public function removeNode( $id ) { |
181
|
|
|
|
182
|
|
|
if( !$node = $this->findNodeById($id) ) |
183
|
|
|
return $this; |
184
|
|
|
|
185
|
|
|
// determine if a database transaction is currently active |
186
|
|
|
// if not we'll start one |
187
|
|
|
$transaction = $this->db->inTransaction(); |
188
|
|
|
|
189
|
|
|
try { |
190
|
|
|
|
191
|
|
|
if( !$transaction ) |
192
|
|
|
$this->db->begin(); |
193
|
|
|
|
194
|
|
|
$diff = (int) $node['rgt'] - (int) $node['lft'] + 1; |
195
|
|
|
|
196
|
|
|
// remove the item from the tree by blanking the left and right indexes |
197
|
|
|
$this->db->execute("UPDATE {$this->table_name} SET lft = 0, rgt = 0 WHERE lft BETWEEN ? AND ?", array($node['lft'], $node['rgt'])); |
198
|
|
|
|
199
|
|
|
$this->db->execute("UPDATE {$this->table_name} SET lft = lft - ? WHERE lft >= ?", array($diff, $node['lft'])); |
200
|
|
|
$this->db->execute("UPDATE {$this->table_name} SET rgt = rgt - ? WHERE rgt >= ?", array($diff, $node['rgt'])); |
201
|
|
|
|
202
|
|
|
if( !$transaction ) |
203
|
|
|
$this->db->commit(); |
204
|
|
|
|
205
|
|
|
} |
206
|
|
|
catch( \Exception $e ) { |
207
|
|
|
if( !$transaction ) |
208
|
|
|
$this->db->rollback(); |
209
|
|
|
throw $e; |
210
|
|
|
} |
211
|
|
|
|
212
|
|
|
return $this; |
213
|
|
|
|
214
|
|
|
} |
215
|
|
|
|
216
|
|
|
public function moveNode( $id, $parent_id ) { |
217
|
|
|
|
218
|
|
|
$node = $this->findNodeById($id); |
219
|
|
|
$parent = $this->findNodeById($parent_id); |
220
|
|
|
|
221
|
|
|
// both source and parent must exist |
222
|
|
|
if( !$node || !$parent ) |
|
|
|
|
223
|
|
|
return $this; |
224
|
|
|
|
225
|
|
|
// determine if a database transaction is currently active |
226
|
|
|
// if not we'll start one |
227
|
|
|
$transaction = $this->db->inTransaction(); |
228
|
|
|
|
229
|
|
|
try { |
230
|
|
|
|
231
|
|
|
if( !$transaction ) |
232
|
|
|
$this->db->begin(); |
233
|
|
|
|
234
|
|
|
$diff = $node['rgt'] - $node['lft'] + 1; |
235
|
|
|
|
236
|
|
|
// remove the node and it's descendents from the tree whilst keeping it's structure, by converting the left and right values to negative numbers |
237
|
|
|
$this->db->execute( |
238
|
|
|
"UPDATE {$this->table_name} SET lft = -(lft - :lft + 1), rgt = -(rgt -:lft + 1) WHERE lft >= :lft AND rgt <= :rgt", |
239
|
|
|
array( |
240
|
|
|
'lft' => $node['lft'], |
241
|
|
|
'rgt' => $node['rgt'] |
242
|
|
|
) |
243
|
|
|
); |
244
|
|
|
|
245
|
|
|
// collapse the gap we just created |
246
|
|
|
$this->db->execute("UPDATE {$this->table_name} SET lft = lft - ? WHERE lft > ?", array($diff, $node['lft'])); |
247
|
|
|
$this->db->execute("UPDATE {$this->table_name} SET rgt = rgt - ? WHERE rgt > ?", array($diff, $node['rgt'])); |
248
|
|
|
|
249
|
|
|
// refresh parent state |
250
|
|
|
$parent = $this->findNodeById($parent_id); |
251
|
|
|
|
252
|
|
|
// create new gap |
253
|
|
|
$this->db->execute("UPDATE {$this->table_name} SET lft = lft + ? WHERE lft > ?", array($diff, $parent['rgt'])); |
254
|
|
|
$this->db->execute("UPDATE {$this->table_name} SET rgt = rgt + ? WHERE rgt >= ?", array($diff, $parent['rgt'])); |
255
|
|
|
|
256
|
|
|
// refresh parent state |
257
|
|
|
$parent = $this->findNodeById($parent_id); |
258
|
|
|
|
259
|
|
|
// fill the gap with the original node, updating the left and right values |
260
|
|
|
$this->db->execute("UPDATE {$this->table_name} SET lft = ? - ? - lft - 1 WHERE lft < 0", array($parent['rgt'], $diff)); |
261
|
|
|
$this->db->execute("UPDATE {$this->table_name} SET rgt = ? - ? - rgt - 1 WHERE rgt < 0", array($parent['rgt'], $diff)); |
262
|
|
|
|
263
|
|
|
// ensure the correct parent is assigned - may have already by done by calling function |
264
|
|
|
$this->db->execute("UPDATE {$this->table_name} SET parent_id = ? WHERE id = ?", array($parent_id, $id)); |
265
|
|
|
|
266
|
|
|
if( !$transaction ) |
267
|
|
|
$this->db->commit(); |
268
|
|
|
|
269
|
|
|
} |
270
|
|
|
catch( \Exception $e ) { |
271
|
|
|
if( !$transaction ) |
272
|
|
|
$this->db->rollback(); |
273
|
|
|
throw $e; |
274
|
|
|
} |
275
|
|
|
|
276
|
|
|
return $this; |
277
|
|
|
|
278
|
|
|
} |
279
|
|
|
|
280
|
|
|
public function getTree( $id, $max_depth = 0, $sort = false ) { |
281
|
|
|
|
282
|
|
|
$node = $this->findNodeById($id); |
283
|
|
|
|
284
|
|
|
if( !$node ) |
|
|
|
|
285
|
|
|
return array(); |
286
|
|
|
|
287
|
|
|
// fetch all the nodes in this sub-tree and calculate their depth |
288
|
|
|
$data = $this->db->getAssoc( |
289
|
|
|
"SELECT n.id, n.{$this->name_field}, COUNT(p.id) - 1 as depth |
290
|
|
|
FROM {$this->table_name} AS n, |
291
|
|
|
{$this->table_name} AS p |
292
|
|
|
WHERE n.lft BETWEEN p.lft AND p.rgt |
293
|
|
|
AND n.lft BETWEEN ? AND ? |
294
|
|
|
GROUP BY n.id |
295
|
|
|
ORDER BY n.lft", |
296
|
|
|
array( |
297
|
|
|
$node['lft'], |
298
|
|
|
$node['rgt'] |
299
|
|
|
) |
300
|
|
|
); |
301
|
|
|
|
302
|
|
|
// as depth is relative to the requested node we need to take into account any ancestors |
303
|
|
|
$max_depth += $this->countAncestors($id); |
304
|
|
|
|
305
|
|
|
$tree = array(); |
306
|
|
|
$path = array(); |
307
|
|
|
$prev = 0; |
308
|
|
|
|
309
|
|
|
foreach( $data as $id => $item ) { |
310
|
|
|
|
311
|
|
|
// too deep - not interested |
312
|
|
|
if( $max_depth && ($item['depth'] > $max_depth) ) |
313
|
|
|
continue; |
314
|
|
|
|
315
|
|
|
// back up a level so remove last too items |
316
|
|
|
if( $item['depth'] < $prev ) { |
317
|
|
|
$path = array_slice($path, 0, -2); |
318
|
|
|
} |
319
|
|
|
// same level so remove previous item |
320
|
|
|
elseif( $item['depth'] == $prev ) { |
321
|
|
|
array_pop($path); |
322
|
|
|
} |
323
|
|
|
|
324
|
|
|
$path[] = $item['name']; |
325
|
|
|
$item['path'] = implode('.', $path); |
326
|
|
|
$tree[$id] = $item; |
327
|
|
|
$prev = $item['depth']; |
328
|
|
|
|
329
|
|
|
} |
330
|
|
|
|
331
|
|
|
if( $sort ) { |
332
|
|
|
uasort($tree, function( $a, $b ) { |
333
|
|
|
return strcmp($a['path'], $b['path']); |
334
|
|
|
}); |
335
|
|
|
} |
336
|
|
|
|
337
|
|
|
return $tree; |
338
|
|
|
|
339
|
|
|
} |
340
|
|
|
|
341
|
|
|
public function visualise( $id, $max_depth = 0, $sort = false ) { |
342
|
|
|
|
343
|
|
|
$tree = array(); |
344
|
|
|
$data = $this->getTree($id, $max_depth, $sort); |
345
|
|
|
|
346
|
|
|
if( $data ) { |
|
|
|
|
347
|
|
|
|
348
|
|
|
$offset = reset($data); |
349
|
|
|
$offset = (int) $offset['depth']; |
350
|
|
|
|
351
|
|
|
foreach( $data as $id => $item ) { |
352
|
|
|
$tree[$id] = str_repeat('|-- ', (int) $item['depth'] - $offset). $item['name']; |
353
|
|
|
} |
354
|
|
|
|
355
|
|
|
} |
356
|
|
|
|
357
|
|
|
return $tree; |
358
|
|
|
|
359
|
|
|
} |
360
|
|
|
|
361
|
|
|
public function rebuild( $sort = false ) { |
362
|
|
|
|
363
|
|
|
try { |
364
|
|
|
$this->db->begin(); |
365
|
|
|
$this->db->execute("UPDATE {$this->table_name} SET lft = 0, rgt = 0"); |
366
|
|
|
$this->rebuildNode($sort, 0); |
367
|
|
|
$this->db->commit(); |
368
|
|
|
} |
369
|
|
|
catch( \Exception $e ) { |
370
|
|
|
$this->db->rollback(); |
371
|
|
|
throw $e; |
372
|
|
|
} |
373
|
|
|
|
374
|
|
|
return $this; |
375
|
|
|
|
376
|
|
|
} |
377
|
|
|
|
378
|
|
|
protected function rebuildNode( $sort = false, $id = 0, $lft = 0 ) { |
379
|
|
|
|
380
|
|
|
$rgt = $lft + 1; |
381
|
|
|
|
382
|
|
|
$order_by = $sort ? "ORDER BY {$this->name_field} ASC" : ''; |
383
|
|
|
|
384
|
|
|
// fetch a list of children of this node |
385
|
|
|
$nodes = $this->db->getCol("SELECT id FROM {$this->table_name} WHERE parent_id = ? {$order_by}", $id); |
386
|
|
|
|
387
|
|
|
foreach( $nodes as $child ) { |
388
|
|
|
$rgt = $this->rebuildNode($sort, $child, $rgt); |
389
|
|
|
} |
390
|
|
|
|
391
|
|
|
$this->db->execute("UPDATE {$this->table_name} SET lft = ?, rgt = ? WHERE id = ?", array($lft, $rgt, $id)); |
392
|
|
|
|
393
|
|
|
return $rgt + 1; |
394
|
|
|
|
395
|
|
|
} |
396
|
|
|
|
397
|
|
|
protected function findNodeById( $id ) { |
398
|
|
|
return $this->db->getRow("SELECT lft, rgt FROM {$this->table_name} WHERE id = ?", $id); |
399
|
|
|
} |
400
|
|
|
|
401
|
|
|
} |
402
|
|
|
|
403
|
|
|
// EOF |
This check marks implicit conversions of arrays to boolean values in a comparison. While in PHP an empty array is considered to be equal (but not identical) to false, this is not always apparent.
Consider making the comparison explicit by using
empty(..)
or! empty(...)
instead.