PHP
laravel

【Laravel】閉包テーブル(Closure Table)でツリーコメントしてみた

SQLアンチパターンの2章 ナイーブツリー(素朴な木)2.5.3.閉包テーブル(Closure Table)。
LaravelからMySQLのデータをいじってみたのだいたい続きから。

環境

PHP 7.1.16
Laravel 5.6.26

元にするDDL

下敷きにするテーブルは以下の通り。出典元から多少簡略化している。

CREATE TABLE comments (
  comment_id SERIAL primary key,
  bug_id BIGINT unsigned not null,
  comment_date DATETIME not null,
  comment TEXT not null
  FOREIGN KEY (bug_id) REFERENCES bugs(bug_id)
);

CREATE TABLE tree_paths (
  ancestor BIGINT UNSIGNED NOT NULL,
  descendant BIGINT UNSIGNED NOT NULL,
  path_length INTEGER,
  PRIMARY KEY (ancestor, descendant)
  FOREIGN KEY (ancestor) REFERENCES comments(comment_id)
  FOREIGN KEY (descendant) REFERENCES comments(comment_id)
);

モデル作成

関連するモデルを作っていく。
枠を生成する。

# コメント関連を一式生成
php artisan make:model -a Comment

# 閉包テーブル生成。これはコントローラは不要
php artisan make:model -mf TreePath

下敷きになるDDLに沿ってマイグレーションを編集する。

database/migrations/2018_07_06_041926_create_tree_paths_table.php
        Schema::create('comments', function (Blueprint $table) {
            $table->bigIncrements('comment_id');
            $table->bigInteger('bug_id')->unsigned();
            $table->datetime('comment_date');
            $table->text('comment');
            $table->timestamps();
            $table->foreign('bug_id')->references('bug_id')->on('bugs');
        });
database/migrations/2018_07_06_041926_create_tree_paths_table.php
        Schema::create('tree_paths', function (Blueprint $table) {
            $table->bigInteger('ancestor')->unsigned();
            $table->bigInteger('descendant')->unsigned();
            $table->integer('path_length')->unsigned();
            $table->timestamps();
            $table->foreign('ancestor')->references('comment_id')->on('comments');
            $table->foreign('descendant')->references('comment_id')->on('comments');
        });

各モデルに主キーを設定する。

app/Bug.php
class Bug extends Model
{
    protected $primaryKey = 'bug_id';
}
app/Comment.php
class Comment extends Model
{
    protected $primaryKey = 'comment_id';
}
app/TreePath.php
class TreePath extends Model
{
    protected $primaryKey = ['ancestor', 'descendant'];
    public $incrementing = false;
}

マイグレーションを流してテーブルを作る。

php artisan migrate

ルートを生成する

Route::resourceがだいたいRailsでいうroutesresources
今回必要なのは/bugs/show/comments/storeだけだが、大雑把に生成したコントローラのルーティングを設定する。

routes/web.php
Route::resource('bugs', 'BugController');
Route::resource('comments', 'CommentController');

テンプレートを編集する

バグの詳細画面を作る

まずバグの詳細画面が表示されるようにする。
何もしなくても$bug変数に対象のIDのBugレコードが入っているので、それをそのままテンプレートに割り当てる。
少々面倒くさいのは、Railsと違ってビューに割り当てる変数を全部コントローラからビューに渡してやる必要があるところ。

app/Http/Controllers/BugController.php
:
    public function show(Bug $bug)
    {
        return view('bugs.show', compact('bug'));
    }
:
resources/views/bugs/show.blade.php
<!doctype html>
<html lang="{{ app()->getLocale() }}">
    <head>
        <meta charset="utf-8">

        <title>Laravel</title>
    </head>
    <body>
        <table>
            <tr>
                <th>ID</th>
                <th>summary</th>
            </tr>
            <tr>
                <td>{{ $bug->bug_id }}</td>
                <td>{{ $bug->summary }}</td>
            </tr>
        </table>
    </body>
</html>

これでhttp://localhost:8000/bugs/1にアクセスする。
するとこんな感じで表示される。

スクリーンショット 2018-07-08 23.29.05.png

コメントの投稿画面を作る

次にツリーコメントを実装していく。
テンプレートにフォームを作る。

resources/views/bugs/show.blade.php
:
        </table>
        <form action="/comments" method="post">
            <div class="form-group">
                <label>コメント</label>
                <textarea type="text" name="comment" value="" class="form-control"></textarea>
            </div>

            <input type="hidden" name="bug_id" value="{{ $bug->bug_id }}">

            {{ csrf_field() }}

            <input type="submit" value="コメントする" class="btn btn-primary">

        </form>
    </body>
</html>

するとこんな感じの画面になる。

スクリーンショット 2018-07-08 23.38.22.png

次にCommentControllerを編集して投稿したコメントがDBに入るようにする。

app/Http/Controllers/CommentController.php
:
use App\Bug;
use App\TreePath;
:
    public function store(Request $request)
    {
        $comment = new Comment();

        $comment->bug_id = $request->bug_id;
        $comment->comment = $request->comment;
        $comment->comment_date = date('Y-m-d');

        $comment->save();

        $tree_path = new TreePath();

        if ($request->ancestor) {
            $tree_path->ancestor = $request->ancestor;
            $ancestor = TreePath::where([ 'descendant' => $request->ancestor ])->first();
            $tree_path->path_length = $ancestor->path_length + 1;
        } else {
            $tree_path->ancestor = $comment->comment_id;
            $tree_path->path_length = 1;
        }
        $tree_path->descendant = $comment->comment_id;

        $tree_path->save();

        $bug = new Bug();

        $bug->bug_id = $request->bug_id;

        return redirect()->route('bugs.show', [$bug]);
    }
:

後から追加するが、上位コメントIDはancestorに入ってくる予定で、無ければ自身のコメントIDになるようになっている。
リダイレクト先のルーティングは、階層を/から.に変更する(bugs/showなのでbugs.show
http://localhost:8000/bugs/1にアクセスしてコメントを投稿してみる。
表示には出ないが、DBコンソールからクエリを発行する等してコメントが入っている事を確認する。

スクリーンショット 2018-07-08 23.40.54.png

コメントをツリー表示する

いよいよコメントのツリー表示をする。

app/Http/Controllers/BugController.php
:
use App\Comment;
use App\TreePath;
:
    public function show(Bug $bug)
    {
        $comments = Comment::where([ 'bug_id' => $bug->bug_id ])->get();
        if ($comments->isEmpty()) {
            $tree_pathes = collect(new TreePath);
        } else {
            $tree_pathes = TreePath::whereIn('descendant', $comments->pluck('comment_id'))->get();
        }
        return view('bugs.show', compact('bug', 'comments', 'tree_pathes'));
    }
:

ゼロ件検索だとエラーになるため、空の場合は別途TreePathモデルの空配列(Collection)を作成している。

resources/views/bugs/show.blade.php
:
        <form action="/comments" method="post">
            <ul>
            {{-- テンプレート内でローカル変数を作る時はPHPの構文を使うとのこと… --}}
            {{-- Rubyではないので、匿名関数からの戻りは都度 return が必要。 --}}
            <?php $root_tree_pathes = $tree_pathes->filter(function ($_tree_path) {
                return $_tree_path->path_length == 1;
            } ); ?>
            @foreach ($root_tree_pathes as $root_tree_path)
                <li>
                    {{-- 雑に言うと、first はRubyでいう detect みたいなもので、 filter はRubyでいう select みたいなもの。 --}}
                    <?php $comment = $comments->first(function ($_comment) use ($root_tree_path) {
                        return $_comment->comment_id == $root_tree_path->descendant;
                    } ); ?>
                    <input type="radio" name="ancestor" value="{{ $comment->comment_id }}"> {{ $comment->comment }}
                    {{-- 匿名関数内で外側のスコープの変数を渡す場合は use を利用する --}}
                    <?php $child_tree_pathes = $tree_pathes->filter(function ($_tree_path) use ($comment) {
                        return $_tree_path->ancestor == $comment->comment_id && $_tree_path->ancestor != $_tree_path->descendant;
                    } ); ?>
                    @if ($child_tree_pathes->isNotEmpty())
                        @include ('comments.recursive', [ 'ancestor' => $comment->comment_id ])
                    @endif
                </li>

            @endforeach
            </ul>

            <div class="form-group">
:
        </form>
    </body>
</html>

ツリーにするために再帰的にテンプレートを使う。

resources/views/comments/recursive.blade.php
<ul>
<?php $ancestor_tree_pathes = $tree_pathes->filter(function ($_tree_path) use ($ancestor) {
    return $_tree_path->ancestor == $ancestor && $_tree_path->ancestor != $_tree_path->descendant;
} ); ?>
@foreach ($ancestor_tree_pathes as $ancestor_tree_path)
    <li>
        <?php $comment = $comments->first(function ($_comment) use ($ancestor_tree_path) {
            return $_comment->comment_id == $ancestor_tree_path->descendant;
        } ); ?>
        <input type="radio" name="ancestor" value="{{ $comment->comment_id }}"> {{ $comment->comment }}
        <?php $child_tree_pathes = $tree_pathes->filter(function ($_tree_path) use ($comment) {
            return $_tree_path->ancestor == $comment->comment_id && $_tree_path->ancestor != $_tree_path->descendant;
        } ); ?>
        @if ($child_tree_pathes->isNotEmpty())
            {{-- テンプレートの再帰呼び出し --}}
            @include ('comments.recursive', [ 'ancestor' => $comment->comment_id ])
        @endif
    </li>

@endforeach
</ul>

親テンプレートの変数は子テンプレートにも引き継がれる模様。

するとこんな感じの表示になる(適当にコメントを投稿しつつ動作確認)。

スクリーンショット 2018-07-08 23.29.10.png

備考

書籍中では、すべてのコメントにancestor == descendanttree_pathesレコードができているが、それは今回の実装では端折っている。
もうちょっといいやり方があるかも知れない。

参考