stefafafan の fa は3つです

"すてにゃん" こと id:stefafafan のブログです

閉包テーブルによる階層構造をPlantUMLやMermaid形式で可視化するツールを作った

最近仕事で触っているサービスが階層構造を扱っていて、それが閉包テーブル (Closure Table) というパターンで実現されていますが、「結局(視覚的に)どういう構造になっているの?」というのがパッとみれたらいいなと思うことがあり、簡単なツールを作ってみることにしました。

できたものはこれです。

github.com

前提: 閉包テーブル (Closure Table) とは

閉包テーブルとは、「SQLアンチパターン」という書籍で紹介されているパターンです。階層的なデータ (例えばSNSのスレッド) をRDBで素朴にモデリングしようとすると、「あるスレッド内に存在するコメント一覧」をまとめてクエリしたくなったとしても、スレッドが無制限に続いたり途中で枝分かれしはじめるとクエリを書くのが大変になっていきます。

実際のデータが含まれているテーブルとは別に、階層関係を持つ「閉包テーブル」というテーブルをわけて持たせるようにすると簡単なクエリで目当てのデータが取得できるようになります。

本書では以下のようなテーブルを紹介しています。 TreePaths テーブルに祖先と子孫のIDを持つカラムがあり、これによってあるコメントに紐づく子孫一覧などが簡単に取得できるようになります。

-- ※ 簡略化しています
CREATE TABLE Comments (
    comment_id, 
    ...
);

CREATE TABLE TreePaths (
    ancestor,   -- 祖先
    descendant, -- 子孫
);

実際に TreePaths テーブルに含まれるデータの例:

ancestor descendant 解説
1 1 最も親となるコメント。自分への参照も持つので、ancestor=descendant
1 2 1→2という関係
1 3 1→3という関係 (直属の子ではなく、孫の関係であっても閉包テーブルとしては持つ)
1 4 ↑と同様
2 2 2つめのコメント
2 3 2→3という関係
2 4 2→4という関係
3 3 3つめのコメント
4 4 4つめのコメント

上記でポイントとなるのは、 1→2→31→2→4 という親子関係になっている場合において、2を飛ばして 1→31→4 の関係も持たせている点です。これによって、 ancestor=1 のデータをクエリするだけで、親もしくは先祖に1がいるコメントを全件取得したり、逆に descendant=4 でクエリすれば4を子もしくは子孫に持つコメントを全件取得できるようになっています。

行一覧を見ても、実際の構造がどうなっているのかよくわからない

先ほどの例で出した表をパッとみても、個人的には結局どういう階層構造になっているのかがいまいちわかりづらいと思いました。頭の中で図を描いていくと最終的にはわかりますが、1階層ずつたどる必要があって地味に難しいです。

途中で行が増減したり子が付け変わったりした場合に、閉包テーブルでは大きく行数が変動するので差分も若干わかりづらい気がします。ということで表題の話になりました(あと、単純にRustでCLIツールを何か作れたら面白そうというモチベーションもありました)。

木構造の可視化

あまり自分でグラフの描画を実装する気持ちになれなかったので、描画部分はPlantUMLやMermaidに任せることにしました。なので、閉包テーブルのデータをPlantUMLなどの別フォーマットに変換するだけのCLIツールを作ることにしました。

PlantUMLのサイトを見たところ、WBS図が一番木に近かったので、これに変換することにしました。

plantuml.com

アスタリスクの数が多ければ階層が深い、みたいな雰囲気です。

@startwbs
* A
** B
*** D
** C
*** E
@endwbs

Mermaidについてはフローチャートを使うことにしました。
mermaid.js.org

PlantUMLと違って、Mermaidのフローチャートは階層を表していないので、自分でいい感じに表す必要があります。

flowchart TD
    A --> B
    B --> D
    A --> C
    C --> E

余談ですが、木構造専用の記法を追加してほしいというissueがMermaidのリポジトリに立っていることを調べていてたまたま発見しました。

github.com

作成したツールの使い方

github.com

JSON形式で閉包テーブルを渡すことを想定しています。また現状 ancestor descendant depth の三つのkeyを持つオブジェクトの配列を想定して作ってあります。

以下のような input.json というファイルが手元にあるとして、

[
	{
		"ancestor": "A",
		"descendant": "A",
		"depth": 0
	},
	{
		"ancestor": "A",
		"descendant": "B",
		"depth": 1
	},
	{
		"ancestor": "A",
		"descendant": "C",
		"depth": 1
	},
	{
		"ancestor": "A",
		"descendant": "D",
		"depth": 2
	},
	{
		"ancestor": "A",
		"descendant": "E",
		"depth": 2
	},
	{
		"ancestor": "B",
		"descendant": "B",
		"depth": 0
	},
	{
		"ancestor": "B",
		"descendant": "D",
		"depth": 1
	},
	{
		"ancestor": "B",
		"descendant": "E",
		"depth": 1
	},
	{
		"ancestor": "C",
		"descendant": "C",
		"depth": 0
	}
]

--format plantuml でコマンドを実行すると、

closure2wbs --input input.json --output out.puml --format plantuml

以下のPlantUML形式のファイルが吐き出されます。

@startwbs
* A
** B
*** D
*** E
** C
@endwbs
PlantUML形式のファイルをプレビューした結果

同じように --format mermaid でコマンドを実行すると、

closure2wbs --input input.json --output out.puml --format mermaid

以下のMermaid形式のファイルが吐き出されます。

flowchart TD
    A --> B
    B --> D
    B --> E
    A --> C
Mermaid形式のファイルをプレビューした結果
実装ロジック

実装ロジックとしては、以下のような流れになっています。

  1. パースしたJSONの中から、一番親のノードを探索
  2. 親のノードを出力しつつ、直属の子 (depth=1) も再帰的に出力する

再帰呼び出ししている箇所はこういう感じです。

github.com

Rustについて

最近「Rustの練習帳」を少しだけ読んでいて、その中でRustでCLIツールを作るというのをやっていて、「もしかしたら簡単に作れそうかも」となったのでやってみました。

CLIツールに必要なオプションのパースとかは https://github.com/clap-rs/clap が勝手にやってくれて楽でした。一方でRustっぽい書き方できている自信はなくて、なんならGitHub Copilotがめちゃくちゃ活躍してくれたので、はたして..という感じです。

終わり

RustでちょっとしたCLIツール作るハードルも下がって、成果物も(PlantUMLやMermaidのおかげさまで)視覚的にわかりやすくてよかったです。ちょっとしたCLIツールを作ってみると結構楽しくておすすめします。