CakePHP の Model::find(‘threaded’) で取得した入れ子状態のデータを、テーブル表示できるように再構築するためのユティリティを作成しました。

https://github.com/zuborawka/cakephp-thread-utility

目次

インストール

composer を使うなら

{
    "require" {
        "zuborawka/cakephp-thread-utility": "v1.0"
    }
}

または github からダウンロードして ThreadUtility.php を適当に配置します。必要なファイルは一つだけです。

composer でインストールした場合はプラグイン名は ‘CakephpThreadUtility’ となり、

CakephpThreadUtility/Utility/ThreadUtility.php

というファイルが追加されます。

使用するときは

App::uses('ThreadUtility', 'CakephpThreadUtility.Utility');

としてください。

※他のクラスとの依存関係が皆無なので設置方法は上記の限りではありません。このファイルのクラスメソッドとプロパティは全て static でそれぞれ self キーワードでアクセスしているので、自分の好きなファイルパスやクラスに変更しても大丈夫です。

使い方

使い方は簡単で、スレッド形式で取得したデータを ThreadUtility::threadToRows() メソッドに渡すだけです。

それによって得られたデータはテーブルの各セルが一行単位の配列になっていて、それらの行の配列がさらにセルの配列になっているのでそれをループさせてテーブルのコードを記述します。

$threaded = $this->Category->find('threaded');
$tableRows = ThreadUtility::threadToRows($threaded);

この操作で、以下のような変換がなされます

変換前

array(
    array(
        'Category' => array(
            'id' => '1', 
            'parent_id' => '0',
            'name' => 'My Favorites',
        ), 
        'children' => array(
            array(
                'Category' => array(
                    'id' => '2',
                    'parent_id' => '1',
                    'name' => 'Soccer',
                ),
                'children' => array(),
            ),
            array(
                'Category' => array(
                    'id' => '3',
                    'parent_id' => '1',
                    'name' => 'Curry',
                ),
                'children' => array(
                    array(
                        'Category' => array(
                            'id' => '4',
                            'parent_id' => '3',
                            'name' => 'Shops',
                        ),
                        'children' => array(),
                    ),
                    array(
                        'Category' => array(
                            'id' => '5',
                            'parent_id' => '3',
                            'name' => 'Recipe',
                        ),
                        'children' => array(),
                    ),
                ),
            ),
        ),
    ),
);

上記のようなスレッド形式のデータが、以下のようになります

変換後

array(
    array(
        array(
            'Category' => array(
                'id' => '1', 
                'parent_id' => '0', 
                'name' => 'My Favorites',
            ), 
            'colspan' => 1, 
            'rowspan' => 3
        ),
        array(
            'Category' => array(
                'id' => '2', 
                'parent_id' => '1', 
                'name' => 'Soccer',
            ), 
            'colspan' => 2, 
            'rowspan' => 1
        )
    ),
    array(
        array(
            'Category' => array(
                'id' => '3', 
                'parent_id' => '1', 
                'name' => 'Curry',
            ), 
            'colspan' => 1, 
            'rowspan' => 2
        ),
        array(
            'Category' => array(
                'id' => '4', 
                'parent_id' => '3', 
                'name' => 'Shop',
            ), 
            'colspan' => 1, 
            'rowspan' => 1
        )
    ),
    array(
        array(
            'Category' => array(
                'id' => '5', 
                'parent_id' => '3', 
                'name' => 'Recipe',
            ), 
            'colspan' => 1, 
            'rowspan' => 1
        ),
    ),
);

つまり、array(1行目の配列(セル配列, セル配列, セル配列, …), 2行目の配列(セル配列, セル配列, …), … ) という構造です。このデータは以下のようにループさせて扱うことを想定しています。

foreach ($tableRows as $tableRow) {
    // $tableRow に1行分のセルが順にセットされている
    foreach ($tableRow as $tableCell) {
        // $tableCell に、Category に加えて colspan, rowspan がセットされている
        $colspan = $tableCell['colspan'];
        $rowspan = $tableCell['rowspan'];
    }
}

使用例

categories

parent_id, lft, rght を設けます。

CREATE TABLE `categories` (
  `id` int(10) unsigned NOT NULL AUTO_INCREMENT,
  `parent_id` int(10) unsigned NOT NULL,
  `lft` int(11) NOT NULL,
  `rght` int(11) NOT NULL,
  `name` varchar(50) NOT NULL,
  PRIMARY KEY (`id`),
  KEY `parent_id` (`parent_id`,`lft`),
  KEY `lft_rght` (`lft`, `rght`)
);

Category

TreeBehavior, $belongsTo[‘ParentCategory’], $hasMany[‘ChildCategory’] をそれぞれセットします。

class Category extends AppModel {

    public $actsAs = array('Tree');

    public $belongsTo = array(
        'ParentCategory' => array(
            'className' => 'Category',
            'foreignKey' => 'parent_id',
        ));

    public $hasMany = array(
        'ChildCategory' => array(
            'className' => 'Category',
            'foreignKey' => 'parent_id',
        ));

}

CategoriesController

任意のスレッドを取得したのち、ThreadUtility::threadToRows() メソッドでデータを再編成します。

class CategoriesController extends AppController
{
    public function threaded($parentId)
    {
        $parent = $this->Category->read(array('lft', 'rght'), $parentId);
        if (empty($parent)) {
            $this->redirect(array('action' => 'index'));
        }
        $conditions = array(
            'Category.lft >' => $parent['Category']['lft']
            'Category.rght <' => $parent['Category']['rght']
        );
        $threaded = $this->Category->find('threaded', compact('conditions'));
        App::uses('ThreadUtility', 'CakephpThreadUtility.Utility');
        $tableRows = ThreadUtility::threadToRows($threaded);
        $this->set(compact('tableRows'));
     }
}

Categories/threaded.ctp

$tableRows を行ごとにループさせ、更にそれらを列ごとにループさせ、セルのデータを “colspan” フィールドと “rowspan” フィールドから取得して利用します。

<table>
    <tbody>
<?php
foreach ($tableRows as $tableRow):
?>
        <tr>
<?php
    foreach ($tableRow as $tableCell):
        $colspan = $tableCell['colspan'] > 1 ? ' colspan="' . $tableCell['colspan'] : '';
        $rowspan = $tableCell['rowspan'] > 1 ? ' rowspan="' . $tableCell['rowspan'] : '';
        $name = h($tableCell['Category']['name']);
        printf('<td%s%s>%s</td>', $colspan, $rowspan, $name);
    endforeach;
?>
        </tr>
<?php
endforeach;
?>
    </tbody>
</table>

出力コード

例えば以下の様なコードが出力されます。

<table>
    <tbody>
        <tr>
            <td rowspan="3">Music</td>
            <td colspan="2">Pops</td>
        </tr>
        <tr>
            <td rowspan="2">Jazz</td>
            <td>Acid</td>
        </tr>
        <tr>
            <td>Smooth</td>
        </tr>
        <tr>
            <td colspan="3">Architecture</td>
        </tr>
        <tr>
            <td>Food</td>
            <td colspan="2">Pasta</td>
        </tr>
    </tbody>
</table>

ブラウザでの見え方

ブラウザではこのような見え方をします。

Music Pops
Jazz Acid
Smooth
Architecture
Food Pasta

おまけ(初心者の方へ)

スレッド形式とは

CakePHPでスレッド形式と呼ばれるデータ構造は、親子関係を表現した入れ子構造の配列を指します。`parent_id` で親要素を参照しているモデルに対して ‘threaded’ というキーワードで find メソッドを呼び出すことで取得可能です。

モデルには parent_id というフィールドさえあれば良いのでクラスファイルには必須の設定は特にありません。

// Model には parent_id を設定するだけ
class Category extends AppModel {}

例えば CategoriesController のメソッド内ではこのように利用できます。

// これで入れ子構造のデータを取得できる
$categories = $this->Category->find('threaded');

ただし、通常はモデルで親子のアソシエーションを張ります。とりわけ親ノードへのアソシエーションがないと更新ロジックの作成が困難に。

// 通常は親と子のアソシエーションを張る
class Category extends AppModel {
    public $belongsTo = array(
        'ParentCategory' => array(
            'className' => 'Category',
            'foreignKey' => 'parent_id',
        ),
    );
    public $hasMany = array(
        'ChildCategory' => array(
            'className' => 'Category',
            'foreignKey' => 'parent_id',
        ),
    );
}

http://book.cakephp.org/2.0/ja/models/retrieving-your-data.html#find-threaded

すでに述べたように、この機能を利用するためには親要素を関連付けるフィールドとして `parent_id` を設ける必要があるだけです。ただし、後述する TreeBehavior と組み合わせて使うことで非常に利用価値が高まる場合が多いので、それも併せて検討するべきではないかと思います。

class Category extends AppModel {
    // TreeBehavior をロードすると便利なことが多い
    public $actsAs = array('Tree');
    public $belongsTo = array(
        'ParentCategory' => array(
            'className' => 'Category',
            'foreignKey' => 'parent_id',
        ),
    );
    public $hasMany = array(
        'ChildCategory' => array(
            'className' => 'Category',
            'foreignKey' => 'parent_id',
        ),
    );
}

また、このデータは内部で一旦 find(‘all’, $options) でフラットなデータを取得したものを入れ子構造に再編成したものです。再編成ロジックそのものは Hash::nest() に記述されており、この Hash::nest() を直接呼び出せば親参照フィールド名を `parent_id` 以外に設定することも可能です。子要素を格納するキー `children` の変更は今のところ不可能です。

ツリービヘイビアとは

CakePHPでは、スレッド形式とは別の方法でツリー構造を実現する方法の一つとして TreeBehavior というビヘイビアが用意されています。

http://book.cakephp.org/2.0/en/core-libraries/behaviors/tree.html

class Category extends AppModel {
    public $actsAs = array('Tree');
}

これは「入れ子集合モデル (Nested set model)」(同じものを Modified Pre-order Tree Traversal と表現することもあるようです)を利用したツリー構造の解決方法で、こちらのページでそのアルゴリズムがわかりやすく解説されています。

http://www.geocities.jp/mickindex/database/db_tree_ns.html

TreeBehavior を利用するにあたっては、上述の `parent_id` の他に、 `lft` と `rght` という二つのフィールドを加える必要があります。これらのフィールドには各ノードの両端の値が TreeBehavior によって自動的にセットされ(「両端」の意味がよくわからない場合は上のリンク先で確認して下さい)、様々な便利なメソッドを利用できます。

また、TreeBehavior が用意するメソッド以外にも lft と rght の利用価値は高く、例えば、スレッド形式で任意の親要素以下のものだけを取得したい場合、親要素の lft と rght の値を検索条件に加える事で容易に実現することが出来ます。

// 親要素の lft と rght で要素を絞り込む
$conditions = array('Category.id' => $parentId);
$fields = array('lft', 'rght');
$recursive = -1;
$parent = $this->Category->find('first', compact('conditions', 'fields', 'recursive'));
$conditions = array(
    'Category.lft >' => $parent['Category']['lft'],
    'Category.rght <' => $parent['Category']['rght'],
);
$childCategories = $this->Category->find('threaded', compact('conditions'));

柔軟な再帰処理を実現するためには、TreeBehavior と ‘threaded’ の組み合わせはとても有効です。

同じく lft と rght を利用するクエリにより、リーフノード(末端のノード)を取得することも容易になります。

$conditions = array('Category.lft' => 'Category.rght - 1');
$allLeaves = $this->Category->find('threaded', compact('conditions'));