[pgsql-jp: 32649] ツリー状のデータの取り扱い

Ryosuke Hosoi hosoi @ ryo.com
2004年 4月 3日 (土) 21:30:41 JST


ほそい%最近メンテばっかりでロク設計してないので暴走気味(?)です

メンテをさぼってネットサーフィンしてるとき
Storing Hierarchical Data in a Database
 http://www.sitepoint.com/article/hierarchical-data-database
というのを見かけました。
RDBで階層型のデータを扱う方法のけっこう面白いアイディアです。


詳細は記事を見てねというとなんだそれ?ってなるのでちょっと説明し
ますと、ツリー状のそれぞれのノードの左と右に根っこの左から順に
数字を割り当てていくと、SELECT一発でいろいろ便利にできるよ、と
いうものです。
サンプルでは
 食べ物
        - 果物
               - 赤色
                      - さくらんぼ
               - 黄色
                      - バナナ
        - 肉
             - 牛肉
             - 豚肉
と、いうツリーに対して
 1食べ物18
        -2果物11
               -3赤色6
                      -4さくらんぼ5
               -7黄色10
                      -8バナナ9
        -12肉17
             -13牛肉14
             -15豚肉16
と、数字を振っています。

こういう具合いに数字をふると
 SELECT * FROM tree WHERE lft BETWEEN 2 AND 11 ORDER BY lft;
で「果物」以下のツリーが一覧でき
 SELECT * FROM tree WHERE lft < 4 AND rgt > 5 ORDER BY lft ASC;
で「さくらんぼ」までの階層が一覧でき
 (right * left - 1) / 2
はそのノードを含む子ノードの数を表してしまうというものです。

記事はmysql+phpなんで、postgresql+pl/pgsqlでやってみよってことで
やってみました。
# 環境は PostgreSQL 7.3.4 i386-redhat-linux-gnu, compiled by GCC 3.3.2です
# plpgsqlつかってるので、当然plpgsqlのインストールが必要です


-- ・先立つモノはテーブル
CREATE TABLE cat (
	cat_id	INTEGER NOT NULL PRIMARY KEY
,	name	TEXT NOT NULL DEFAULT ''
,	p_id	INTEGER NOT NULL DEFAULT 1
,	l_id	INTEGER NOT NULL DEFAULT 0
,	r_id	INTEGER NOT NULL DEFAULT 0
);
-- 記事では tree.title tree.parentってなってましたが、僕なりの流儀で
-- テーブル設計してます

-- ・そしてとりあえずルートノードは入れてしまう
INSERT INTO cat (
 cat_id, name, p_id, l_id, r_id
) VALUES (
 1, 'Root', 1, 1, 2
);
-- ルートノードは0じゃなくて1にします。0とかNULLは可能な限り使わない
-- 流儀なのです

-- ・自己参照できちゃった
ALTER TABLE cat ADD CONSTRAINT cat_p_id_fk FOREIGN KEY (p_id) REFERENCES cat (cat_id);
-- このCONSTRAINTは、データ構造を守るためにはあったほうがいいけど、
-- 存在としてはどうなんだろう?

-- ・記事中にある左と右を勝手に数えてくれる再帰関数のplpgsql版
CREATE OR REPLACE FUNCTION cat_rebuild (INTEGER, INTEGER) RETURNS INTEGER AS '
DECLARE
	v_p_id	ALIAS FOR $1;
	v_l_id	ALIAS FOR $2;
	v_r_id	INTEGER;
	catrow	cat%ROWTYPE;
	do_update	BOOLEAN := false;
BEGIN
	v_r_id := v_l_id + 1;
	
	FOR catrow IN
	SELECT *
	  FROM cat
	 WHERE p_id = v_p_id
	   AND cat_id != 1
	LOOP
		v_r_id := cat_rebuild(catrow.cat_id, v_r_id);
	END LOOP;
	
	SELECT INTO catrow * FROM cat WHERE cat_id = v_p_id;
	IF FOUND THEN
		IF catrow.l_id != v_l_id OR catrow.r_id != v_r_id THEN
			do_update := true;
		END IF;
	ELSE
		do_update := false;
	END IF;
	
	IF do_update THEN
		UPDATE cat
		   SET l_id = v_l_id, r_id = v_r_id
		 WHERE cat_id = v_p_id;
	END IF;
	
	v_r_id := v_r_id + 1;
	RETURN v_r_id;
END;
' LANGUAGE 'plpgsql';

-- ・ルートノードから全部数え直す関数
CREATE OR REPLACE FUNCTION cat_rebuild () RETURNS INTEGER AS '
DECLARE
	val_r	INTEGER;
BEGIN
	val_r := cat_rebuild(1, 1) - 1;
	
	RETURN val_r;
END;
' LANGUAGE 'plpgsql';

-- ・左と右を指定して、自分を*含まない*子供の数を返す関数
CREATE OR REPLACE FUNCTION cat_childs (INTEGER, INTEGER) RETURNS INTEGER AS '
DECLARE
	l_id	ALIAS FOR $1;
	r_id	ALIAS FOR $2;
BEGIN
	RETURN (r_id - l_id + 1) / 2 - 1;
END;
' LANGUAGE 'plpgsql';

-- ・cat_id指定して、自分を*含まない*子供の数を返す関数
CREATE OR REPLACE FUNCTION cat_childs (INTEGER) RETURNS INTEGER AS '
DECLARE
	cat_id	ALIAS FOR $1;
	catrow	cat%ROWTYPE;
BEGIN
	SELECT INTO catrow c.* FROM cat AS c WHERE c.cat_id=cat_id;
	
	IF FOUND THEN
		RETURN cat_childs(catrow.l_id, catrow.r_id);
	END IF;
	
	RETURN -1;
END;
' LANGUAGE 'plpgsql';

-- ・左と右を指定して、自分の居場所の深さを返す関数(ルートを0の深さとする)
CREATE OR REPLACE FUNCTION cat_depth (INTEGER, INTEGER) RETURNS INTEGER AS '
DECLARE
	l_id	ALIAS FOR $1;
	r_id	ALIAS FOR $2;
	depth	INTEGER;
BEGIN
	SELECT INTO depth count(*) FROM cat AS c
	  WHERE c.l_id <= l_id AND c.r_id >= r_id;
	
	RETURN depth - 1;
END;
' LANGUAGE 'plpgsql';

-- ・cat_idを指定して、自分の居場所の深さを返す関数(ルートを0の深さとする)
CREATE OR REPLACE FUNCTION cat_depth (INTEGER) RETURNS INTEGER AS '
DECLARE
	cat_id	ALIAS FOR $1;
	catrow	cat%ROWTYPE;
BEGIN
	SELECT INTO catrow c.* FROM cat AS c WHERE c.cat_id=cat_id;
	
	IF FOUND THEN
		RETURN cat_depth(catrow.l_id, catrow.r_id);
	END IF;
	
	RETURN -1;
END;
' LANGUAGE 'plpgsql';

-- ・左と右を指定して、フルパス表記の文字列を返す関数
CREATE OR REPLACE FUNCTION cat_path (INTEGER, INTEGER) RETURNS TEXT AS '
DECLARE
	l_id	ALIAS FOR $1;
	r_id	ALIAS FOR $2;
	fullpath	TEXT;
	catrow	cat%ROWTYPE;
BEGIN
	fullpath := '''';
	
	FOR catrow IN
	 SELECT c.*
	   FROM cat AS c
	  WHERE c.l_id <= l_id AND c.r_id >= r_id
	  ORDER BY c.l_id
	LOOP
		fullpath := fullpath || ''/'' || catrow.name;
	END LOOP;
	
	RETURN fullpath;
END;
' LANGUAGE 'plpgsql';

-- ・cat_idを指定して、フルパス表記の文字列を返す関数
CREATE OR REPLACE FUNCTION cat_path (INTEGER) RETURNS TEXT AS '
DECLARE
	cat_id	ALIAS FOR $1;
	catrow	cat%ROWTYPE;
BEGIN
	SELECT INTO catrow c.* FROM cat AS c WHERE c.cat_id=cat_id;
	
	IF FOUND THEN
		RETURN cat_path(catrow.l_id, catrow.r_id);
	END IF;
	
	RETURN '''';
END;
' LANGUAGE 'plpgsql';

-- ・テーブルに操作をするときは、とりあえず左と右を考えなくても
-- いいように、このトリガーで全部やっちゃいます
CREATE OR REPLACE FUNCTION cat_trg () RETURNS TRIGGER AS '
DECLARE
	do_rebuild	BOOLEAN;
	val_r	INTEGER;
BEGIN
	do_rebuild := true;
	
	IF texteq(TG_OP, ''UPDATE'') THEN
		IF NEW.p_id = OLD.p_id THEN
			do_rebuild := false;
		END IF;
	END IF;
	
	IF do_rebuild THEN
		val_r := cat_rebuild();
	END IF;
	
	RETURN NULL;
END;
' LANGUAGE 'plpgsql';
CREATE TRIGGER cat_trg AFTER INSERT OR UPDATE OR DELETE ON cat
 FOR EACH ROW EXECUTE PROCEDURE cat_trg();


これで準備はOKです


-- こんなデータを入れて
insert into cat (cat_id, name,p_id) values (2, 'Computer', 1);
insert into cat (cat_id, name,p_id) values (3, 'Internet', 1);

insert into cat (cat_id, name,p_id) values (4, 'Windows', 2);
insert into cat (cat_id, name,p_id) values (5, 'Mac', 2);
insert into cat (cat_id, name,p_id) values (6, 'Unix', 2);
insert into cat (cat_id, name,p_id) values (7, 'PDA', 2);
insert into cat (cat_id, name,p_id) values (8, 'Others', 2);

insert into cat (cat_id, name,p_id) values (9, 'Linux', 6);
insert into cat (cat_id, name,p_id) values (10, 'FreeBSD', 6);
insert into cat (cat_id, name,p_id) values (11, 'Others', 6);

insert into cat (cat_id, name,p_id) values (12, 'ISP', 3);
insert into cat (cat_id, name,p_id) values (13, 'Cafe', 3);

-- こんなSELECTをしてみたり
select *,cat_depth(l_id,r_id),cat_childs(l_id,r_id),cat_path(l_id,r_id)
 from cat order by l_id;

-- こんなUPDATEをしてみたり
update cat set p_id=8 where cat_id=7;

-- あとは、お好きにどうぞ :)


左と右の値を更新するトリガは、INSERT/UPDATE/DELETEそれぞれ別で
書くともうちょっと効率良くできそうなんですが、ちょっと書いてみ
たらバグりまして、デバッグに時間がかかりそうなのでやめました^^;

cat_id指定でノードまでのパスをSELECTできる、WHERE用の関数をつくっ
ても面白い、というか、左と右を完全に隠してしまうのがオブジェクトっ
ぽいですかね?


あと、これを試しちゃってから思ったのですが、せっかくPostgreSQL
なんだから、幾何データ型のpointやpathをつかってツリーを表現する
ほうが本道のような気もしました。


-- 
 Ryosuke Hosoi / 細井 良祐
 mailto:hosoi @ ryo.com http://www.ryo.com/
 PGP Public Key http://www.ryo.com/ryo/hosoi.ryo.com.asc
 fingerprint = 4F39 61B0 2034 3A5C DFE8  FBCB 7B99 90CF EBE1 A3F3



pgsql-jp メーリングリストの案内