2017/02/02

在 Database 裡面儲存樹的內容


前一陣子工作需要在 Database 裡面儲存一棵樹的資料
在 SlideShare 上看了一篇
Models for Hierarchical Data with SQL and PHP - by Bill Karwin, Percona Inc.
覺得蠻有趣的
特別用部落格整理一下
以下的圖片都來自原作者的 slides
SQL statements 的部分有些是作者 slides 內的
有些是我自己在 sqlfiddle 上試寫的
以 SQLite 支援的語法為主

作者介紹了四種實作
分別為 Adjacency List、Path Enumeration、Nested Sets 以及 Closure Table
並且很貼心的作了一張表來比較


作者以一個 bug 回報系統的討論區來作例子
可以想成一個節點就是一篇文章
你回覆某一篇你就成為該篇的子節點
(以下把節點稱作 Node)


Adjacency List


Adjacency List 算是大部分人第一時間會想到的方式
就是每一筆記錄一個 Node
然後用一個欄位來記 parent Node的 ID


新增,刪除,移動一個 Node, 或是移動子樹都很容易
  1. -- 新增一個 Node Node 5
  2. INSERT INTO Comments (parent_id, author, comment)
  3. VALUES (5,
  4. 'Fran',
  5. 'I agree!');
  6.  
  7. -- 刪除 Node 7
  8. DELETE
  9. FROM Comments
  10. WHERE comment_id = 7;
  11.  
  12. -- 移動 Node 6 Node 3 底下 (若 Node 6 不是 leaf 則會移動整個 subtree)
  13. UPDATE Comments
  14. SET parent_id = 3
  15. WHERE comment_id = 6;
查詢某一個 Node 的 children (只向下一個 level) 也不難
  1. -- 取得 Node 2 children
  2. SELECT c2.*
  3. FROM Comments c1
  4. LEFT JOIN Comments c2 ON (c2.parent_id = c1.comment_id)
  5. WHERE c1.comment_id = 2;
但是要取得整顆子樹就不容易,因為必須遞迴地拿 (文章有介紹寫法,有興趣的可以去看)
因此刪除子樹也不容易

Path Enumeration


Path Enumeration 紀錄更多資訊,不像 Adjacency List 只記錄 parent 的 id
這個方法紀錄了從 root 到自己的完整路徑


新增,刪除,移動一個 Node, 或是移動子樹也不難
  1. -- 新增一個 Node Node 5
  2. INSERT INTO Comments (author, comment)
  3. VALUES ('Fran',
  4. 'I agree');
  5.  
  6. UPDATE Comments
  7. SET path =
  8. (SELECT path
  9. FROM Comments
  10. WHERE comment_id = 5) || last_insert_rowid() || '/'
  11. WHERE comment_id = last_insert_rowid();
  12.  
  13. -- 刪除 Node 7
  14. DELETE
  15. FROM Comments
  16. WHERE path LIKE
  17. (SELECT path
  18. FROM Comments
  19. WHERE comment_id = 7) || '%';
  20.  
  21. -- 移動 Node 6 Node 3 底下 (若 Node 6 不是 leaf 則會移動整個 subtree)
  22. UPDATE Comments
  23. SET path =
  24. (SELECT path
  25. FROM Comments
  26. WHERE comment_id = 3) || substr(path, instr(path, '6'))
  27. WHERE path LIKE
  28. (SELECT path
  29. FROM Comments
  30. WHERE comment_id = 6) || '%';
與 Adjacent List 不同的是,查詢某一個 Node 的子樹不難
  1. -- 取得 Node 2 subtree
  2. SELECT *
  3. FROM Comments
  4. WHERE path LIKE
  5. (SELECT path
  6. FROM Comments
  7. WHERE comment_id = 2) || '%/%';
雖然投影片中說查一層 children 很難
但是我試了一下好像也還好
  1. -- 取得 Node 2 children
  2. SELECT *
  3. FROM Comments
  4. WHERE path LIKE
  5. (SELECT path
  6. FROM Comments
  7. WHERE comment_id = 2) || '_/';

Nested Sets


Nested Sets 蠻特別的,每一筆資料存著左右兩個數字
左數是一個比所有子節點的數字小的一個數 (每個子節點也是會有左右兩個數字)
右數則是一個比所有子節點的數字都大的一個數
所以 root 的左數一定是所有節點的左右數中最小的
反之 root 的右數則是所有節點的數字中最大的
頭暈了吧 XDDDDD
一圖勝萬言


查子樹很容易
  1. -- 取得 Node 2 subtree
  2. SELECT descendant.comment_id
  3. FROM Comments parent
  4. JOIN Comments descendant ON (descendant.nsleft BETWEEN parent.nsleft AND parent.nsright)
  5. WHERE parent.comment_id = 2
  6. AND descendant.comment_id <> 2;
但是新增,刪除,移動一個 Node, 或是移動子樹都不容易
因為你會必須更動很多相關連的左右數
以新增一個 Node 為例
  1. -- 新增一個 Node Node 5
  2. SELECT nsleft,
  3. nsright
  4. FROM Comments
  5. WHERE comment_id = 5;
  6. -- 把結果存在 $nsleft_5, SQLite 沒有支援 local variable
  7.  
  8. UPDATE Comments
  9. SET nsleft = CASE
  10. WHEN nsleft >= ($nsleft_5 + 1) THEN nsleft + 2
  11. ELSE nsleft
  12. END,
  13. nsright = nsright + 2
  14. WHERE nsright >= $nsleft_5;
  15.  
  16. INSERT INTO Comments (nsleft, nsright, author, comment)
  17. VALUES ($nsleft_5 + 1,
  18. $nsleft_5 + 2,
  19. 'Fran',
  20. 'I agree!');
slides 內也說明了一下查一層 children 的困難處
其實作者也把 SQL statements 寫出來了
只是真的不是很直覺,需要稍微在腦中想一下才知道他為什麼那樣寫

Closure Table


這是唯一用到兩張表的方法
一張表存 Nodes 的資訊
以 bug 回報系統來說,就是像作者,內文之類
另一張表存每一個 Node 到它自己所有的 descendants 的路徑
一樣一圖勝萬言


說真的我第一次看到這個方法時讚嘆不已
因為沒有研究過這個問題
所以沒想到有這樣的設計方式
投影片中更提到 TreePaths 這張表可以多存一個路徑長度的資訊
這樣在作一些查詢時會更容易一點


新增 Node 的時候要到 TreePaths 的表裡面建立相對應的路徑
  1. -- 新增一個 Node Node 5
  2. INSERT INTO Comments(author, comment)
  3. VALUES ('Fran',
  4. 'I agree!');
  5.  
  6. -- 新增 paths, 複製指到 Node 5 的所有 paths 並把 descendants 換成新的
  7. INSERT INTO TreePaths (ancestor, descendant, length)
  8. SELECT ancestor,
  9. last_insert_rowid(),
  10. length + 1
  11. FROM TreePaths
  12. WHERE descendant = 5
  13. UNION ALL
  14. SELECT last_insert_rowid(),
  15. last_insert_rowid(),
  16. 0; -- 加上一條自己指自己的 path
刪除也蠻簡單的
  1. -- 刪除 Node 7
  2. DELETE
  3. FROM TreePaths
  4. WHERE descendant = 7;
  5.  
  6. DELETE
  7. FROM Comments
  8. WHERE comment_id = 7;
利用 TreePaths 裡的 length 欄位
在查詢子樹或是一層的 children 時很方便
  1. -- 取得 Node 2 children
  2. SELECT c.*
  3. FROM Comments c
  4. JOIN TreePaths t ON (c.comment_id = t.descendant)
  5. WHERE t.ancestor = 2
  6. AND t.length = 1;
  7.  
  8. -- 取得 Node 2 subtree
  9. SELECT c.*
  10. FROM Comments c
  11. JOIN TreePaths t ON (c.comment_id = t.descendant)
  12. WHERE t.ancestor = 2
  13. AND t.length > 0;
移動應該是最麻煩的
因為你要先刪掉所有相關的路徑
再重新建立移動後新的路徑
  1. -- 移動 Node 6 Node 3 底下 (若 Node 6 不是 leaf 則會移動整個 subtree)
  2. DELETE
  3. FROM TreePaths
  4. WHERE descendant IN
  5. (SELECT descendant
  6. FROM TreePaths
  7. WHERE ancestor = 6 )
  8. AND ancestor NOT IN
  9. (SELECT descendant
  10. FROM TreePaths
  11. WHERE ancestor = 6);
  12.  
  13. INSERT INTO TreePaths (ancestor, descendant, length)
  14. SELECT supertree.ancestor,
  15. subtree.descendant,
  16. supertree.length + subtree.length + 1
  17. FROM TreePaths AS supertree
  18. JOIN TreePaths AS subtree
  19. WHERE subtree.ancestor = 6
  20. AND supertree.descendant = 3;
最後是把整棵樹從 root 開始列出來所有 Nodes
  1. SELECT n.*,
  2. p.ancestor AS parent
  3. FROM TreePaths AS t
  4. INNER JOIN Nodes AS n ON t.descendant = n.treeNodeId
  5. LEFT JOIN TreePaths AS p ON p.descendant = n.treeNodeId
  6. AND p.length = 1
  7. WHERE (t.ancestor = 1)
  8. AND (p.ancestor IS NOT NULL
  9. OR n.treeNodeId = 1)
  10. ORDER BY t.length;