MySQL でツリー構造 by 経路列挙モデル

備忘録。

SQL アンチパターンに載っていた木構造RDB で表現するためのモデルの1つ、経路列挙モデルの MySQL による実装について。

今回は以下の制約をかける。

  • 簡略のため、各ノードは id のみを持つ
  • ノードの削除はサポートしない
  • ノードの追加は葉のみ

以下の5つのインタフェースを実装することを考える。

  • ノードを追加する addNode(id, parentId)
  • 子孫ノードを全て返す getDescendantsOf(id)
  • 先祖ノードを全て返す getAncestorsOf(id)
  • 直接の子ノードを全て返す getChildrenOf(id)
  • 直接の親ノードを返す getParentOf(id)

スキーマは以下のようになる:

CREATE TABLE tree (
  id INTEGER NOT NULL PRIMARY KEY,
  path TEXT NOT NULL
);

path には / を区切り文字として id を元にした経路を列挙することとする。(例: ID1のノードの下にID2のノードがある場合、ID2のノードの path1/2/ となる)

以降、MyBatis で実装することを前提に、各操作の SQL がどうなるかを見ていく。

addNode(idm parentId)

INSERT INTO tree (id, path)
  SELECT #{id}, CONCAT(parent.path, '/', #{id}) FROM tree AS parent
  WHERE parent.id = #{parentId};

見ての通り。

getDescendantsOf(id)

SELECT other.id FROM tree AS other
  INNER JOIN tree AS own ON own.id = #{id}
  WHERE other.path LIKE CONCAT(own.path, '%') AND other.id != #{id};

パスが 1/2/ のノードの子孫を取る場合、それらは全て 1/2/ から始まる。したがってパスが 1/2/% とマッチする(自分以外の)レコードを SELECT すればよい。

getAncestorsOf(id)

SELECT other.id FROM tree AS other
  INNER JOIN tree AS own ON own.id = #{id}
  WHERE own.path LIKE CONCAT(other.path, '%') AND other.id != #{id};

1/2/3/ のノードに対して 1 と 2 を返したい。この場合は getDescendantsOf とは逆に、先祖ノードのパスが対象のノードに対して前方一致することを利用することになる。1/%1/2/%1/2/3/ とマッチするため対象となるが、一方で 1/2/3/4/%1/2/3/ とはマッチしない。

getParentOf(id)

SELECT other.id FROM tree AS other
  INNER JOIN tree AS own ON own.id = #{id}
  WHERE other.path = LEFT(own.path, LOCATE(CONCAT('/', own.id, '/'), own.path));

パスに対してごちゃごちゃと関数を適用しているが、やっていることはパスが 1/2/3/ のノードの場合に、1/2/ のパスのノードを引き当てているだけ。

getChildrenOf(id)

SELECT other.id FROM tree AS other
  INNER JOIN tree AS own ON own.id = #{id}
  WHERE other.path LIKE CONCAT(own.path, '%') AND other.id != #{id}
  AND LENGTH(other.path) - LENGTH(REPLACE(other.path, '/', '')) = LENGTH(own.path) - LENGTH(REPLACE(own.path, '/', '')) + 1;

getDescendantsOf での絞り込みに加えて、木の深さがちょうど1多いことを条件に加えればいい。ノードの深さはパスに含まれる区切り文字の数なので、文字数ベースで計算できる。