はじめに
ショッピングサイトの商品カテゴリーのような階層型のデータをSQLデータベースで管理する場合には、以下のような隣接リストを用いるのがわかりやすくて一般的です。
CREATE TABLE Category (
id SERIAL PRIMARY KEY,
parent_id BIGINT UNSIGNED,
name TEXT,
FOREIGN KEY (parent_id) REFERENCES Category(id)
)
例えば某ショッピングサイトで出品する際のカテゴリーを調べると、下記のようなデータとなっています。(不要な列は省略してあります)
id parent_id name
---------------------------------------
1 0 "レディース"
2 0 "メンズ"
3 1 "トップス"
4 1 "ジャケット/アウター"
5 2 "トップス"
6 2 "ジャケット/アウター"
7 6 "テーラードジャケット"
...
このようなデータからカテゴリーを選択するUIを作成する際に、List
のままでセレクトボックスをレンダリングしイベントのハンドリングをしていたのですが、Elmでディレクトリ構造エディタを作るという記事を読み、木構造に変換をしてから操作するのが良さそうだと気がつきました。ところが、肝心の変換をするコードがなかなか見つからず、しばらく悩んだ末に自分なりの答えが見つかったので記事にしてみました。
木構造にも色々な種類がありますが、カテゴリーのような複数の子を持つ階層構造の場合、先の記事でも使われている多分木(multi-way tree/rose tree)と呼ばれるデータ構造が使われます。多分木はTree a (List (Tree a))
といった再帰を伴う構造です。イミュータブルな関数型言語において木構造の任意のデータにアクセスする場合、毎回先頭から辿らなければなりませんが、これを効率よくするためにZipper
というデータ構造が用意されています。詳細についてはelm-monocle Lens/Optionalで多分木操作を隠蔽の記事の前半部分や次のパッケージのドキュメントをご参照ください。
TreeをキーワードにElmのパッケージサイトで検索をすると、多数のパッケージがヒットして迷ってしまいますが、この記事ではhttps://package.elm-lang.org/packages/zwilias/elm-rosetree/1.5.0/を使いました。
データの定義
先程のテーブルの各行を次のようなレコード型として定義します。
type alias Node =
{ id : Int
, parentId : Int
, name : String
}
すると元となるデータの全体は下記のようになります。
data : List Node
data =
[ Node 1 0 "レディース"
, Node 2 1 "トップス"
, Node 3 2 "ポロシャツ"
...
またTEA1におけるModel
の一部として多分木を定義します。
type alias Model =
{ tree : Tree Node
}
ここではどちらも
Node
型を使っていますが同じ型である必要はありません。
変換の手順
目的はdata
をModel.tree
に変換することで、次のような型をもつ関数が必要であることに気がつきます。
fromAdjacencyList : List Node -> Tree Node
こういった場合にElmのような言語では再帰の考え方が重要となってくるのですが、長年for
ループに慣れた頭ですと混乱しやすいため、まずは愚直にコードを書いてみます。
data
の各要素にはparentId
が含まれていますので、Model.tree
の中から該当するid
を持つノードを見つけてその子ノードとして追加してけば良さそうです。
1番目の階層をつくる
まずは木構造の根となるrootNode
を定義します(Model.tree
の初期値にもなります)。
rootNode : Tree Node
rootNode =
Tree.singleton
{ id = 0
, parentId = 0
, name = "ルート"
}
Tree.singleton
関数は子ノードが空のリストであるノードを生成します。次にdata
の最初の要素は
node1 : Node
node1 =
{ id = 1
, parentId = 0
, name = "レディース"
}
-- 以下では便宜的に同じようにnode2,node3としてdataの各要素を参照します。
となりますので、id == 0
であるノードすなわちrootNode
の子ノードとして追加します。
newTree1 = Tree.prependChild (Tree.singleton node1) rootNode
ここで追加なのでTree.appendChild
という関数を使いたくなりますが、パッケージのドキュメントを読むと、実際の処理がchildren ++ [ newChild ]
となって効率が悪いため推奨されていません。特別な理由がない限り、Tree.prependChild
を使うのが良いとされています。
2番目の階層をつくる
2番目の要素node2
はparentId == 1
であるため、newTree1
の中でid == 1
であるノードを探し、その子ノードとして追加します。Elmでは全てイミュータブルなためrootNode
ではなくてnewTree1
に追加する必要があります。ここで登場するのがTree.Zipper
に定義された
findFromRoot : (a -> Bool) -> Zipper a -> Maybe (Zipper a)
という関数です。最初の引数の条件を満たすノードをZipper a
の中から探して、結果をMaybe (Zipper a)
として返す関数です。まずはnewTree1
をZipper Node
に変換する必要があります。
zipper : Zipper Node
zipper = Zipper.fromTree newTree1
ここで探しているのはnode2
の親となりうる要素、すなわち木構造の中でid
がnode2.parentId
と同じであることが条件となりますので、
maybeZipper
= findFromRoot (\node -> node.id == node2.parentId) zipper
となります。この関数の返り値はMaybe (Zipper Node)
となるため、パターンマッチの登場です。
newTree2 =
case maybeZipper of
Nothing ->
newTree1
Just zipper ->
prependNodeToZipper node2 zipper
|> Zipper.toTree
Nothing
の場合はノードが孤児状態ということで本来はありえないはずなのですが2、ここではノードの追加は諦めて元のnewTree1
をそのまま返します。一方Just zipper
の場合のzipper
は木構造を辿って見つけた、条件に一致するノードに注目した状態となります。これでnode2
を追加するべきノードが見つかりました。
-- Zipper.tree : Zipper a -> Tree a
-- Zipperがフォーカスしているノードを起点にTreeに変換
subTree = Zipper.tree zipper
|> Tree.prependChild (Tree.singleton node2)
次に、Zipper.tree
でzipper
が注目しているノードを起点とするTree
に変換して全体の木構造から分離し、新しく作られた木構造の子ノードとしてnode2
を先程と同じように追加します。このsubTree
をzipper
が注目している位置にある木構造と置き換えます。
-- replaceTree : Tree a -> Zipper a -> Zipper a
-- Zipperがフォーカスしているノードを新しいノードで置換
newZipper = Zipper.replaceTree subTree zipper
これらをまとめて関数にすると以下のようになります。
prependNodeToTree : Node -> Zipper Node -> Zipper Node
prependNodeToTree node zipper =
Zipper.replaceTree (
Zipper.tree zipper
|> Tree.prependChild (Tree.singleton node)
) zipper
N番目の階層をつくる
2番目の階層の場合と同じですが、これまで分割して書いてきた処理を下記のような関数にまとめて書くことができます。
foldFunc : Node -> Tree Node -> Tree Node
foldFunc nodeN tree =
case Zipper.findFromRoot (\node -> node.id == nodeN.parentId) (Zipper.fromTree tree) of
Just zipper ->
prependNodeToTree nodeN zipper
|> Zipper.toTree
Nothing ->
tree
この関数を使ってこれまでの処理を書き直してみると、
newTree1 = foldFunc node1 rootNode
newTree2 = foldFunc node2 newTree1
...
newTreeN = foldFunc nodeN newTree(N-1)
となりますが、実際には以下のように再帰的に関数foldFunc
を適用していることがわかります。
newTreeN = foldFunc nodeN (... foldFunc node3 (foldFunc node2 (foldFunc node1 rootNode)))
Haskellと引数の順番が異なっていて一瞬混乱しましたがElmの場合はこの順番でよさそうです
こういった繰り返し関数を適用するのに便利な関数List.foldl
が用意されています。
newTreeN = List.foldl foldFunc rootNode data
ここではdata
の要素を一つ一つ取りだして関数foldFunc
を適用しその結果を次の要素と組み合わせてリストの終わりで繰り返し適用していきます。rootNode
は木構造の初期値となります。求めていた関数として書き出して完成です!
fromAdjacencyList : Tree Node -> List Node -> Tree Node
fromAdjacencyList acc list =
List.foldl foldFunc acc list
-- または
fromAdjacencyList : Tree Node -> List Node -> Tree Node
fromAdjacencyList =
List.foldl foldFunc
おわりに
一通り説明をさせていただきましたが、文才がないため読みづらかったらすみません。最初はやっぱり紙に書いてあーだこーだ考えてブラッシュアップしていくのが良さそうです。そして、ある程度形になったら、実際にコードとして書いて試していくわけですが、Elmのコンパイラが優秀なおかげで、手書きと同じような感覚で進められるのがとても素晴らしいと思います。最後の方はだいぶ手抜きになってきた気もしますが不慣れなためご勘弁(汗
- 「Elmでディレクトリ構造エディタを作る」の記事と同じようなEllieのサンプルを用意しましたのでお試しください。
- この木構造をベースに、メ○カリのようなカテゴリー選択をするUIもできてはいるのですがそれはまたの機会に…
Tree
とZipper
のおかげで、不具合なく書けたと思います。
-
The Elm Architecture : https://guide.elm-lang.jp/architecture/ ↩
-
データベースから受け取ったデータは、必ずしも階層の順番に並んでいるとは限りませんので、そのまま使うと孤児が発生する可能性があります。データベース側に階層の深さも一緒に記録してあるはずなので、そちらを基準にソートしてから適用すれば孤児は発生しないはずです。もし孤児が発生するとすれば、それは元になるデータに問題があるわけですが、クライアントサイドでエラー処理が必要かもしれません。 ↩