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

[SQLアンチパターン](https://www.oreilly.co.jp/books/9784873115894/)の2章 ナイーブツリー（素朴な木）2.5.3.閉包テーブル（Closure Table）。
[LaravelからMySQLのデータをいじってみた](https://qiita.com/b_a_a_d_o/items/bfafd5f94b64bfd8253e)のだいたい続きから。

## 環境
PHP 7.1.16
Laravel 5.6.26

## 元にするDDL

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

```sql
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)
);
```

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

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

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

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

```php: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');
        });
```

```php: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');
        });
```

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

```php:app/Bug.php
class Bug extends Model
{
    protected $primaryKey = 'bug_id';
}
```

```php:app/Comment.php
class Comment extends Model
{
    protected $primaryKey = 'comment_id';
}
```

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

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

```bash
php artisan migrate
```

## ルートを生成する

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

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

## テンプレートを編集する
### バグの詳細画面を作る

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

```php:app/Http/Controllers/BugController.php
:
    public function show(Bug $bug)
    {
        return view('bugs.show', compact('bug'));
    }
:
```

```php: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](https://qiita-image-store.s3.amazonaws.com/0/9104/8eaecadb-f774-943f-cb7e-10cda3d5f173.png)


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

```html: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](https://qiita-image-store.s3.amazonaws.com/0/9104/7c69d77e-ac7d-2ead-7d12-aa0b53712e1d.png)


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

```php: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](https://qiita-image-store.s3.amazonaws.com/0/9104/7c728396-d9aa-f6da-2245-795b94ec78dc.png)


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

```php: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`）を作成している。

```php: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>
```

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

```php: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](https://qiita-image-store.s3.amazonaws.com/0/9104/2047866f-f516-0f84-2411-eb09fbc90399.png)


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


## 参考
- [SQLアンチパターン](https://www.oreilly.co.jp/books/9784873115894/)
- [php - Laravel 5.2 : How to get a variable in a subView of a parentView from another subView? - Stack Overflow](https://stackoverflow.com/questions/38124846/laravel-5-2-how-to-get-a-variable-in-a-subview-of-a-parentview-from-another-su)
- [Blade Templates - Laravel - The PHP Framework For Web Artisans](https://laravel.com/docs/5.6/blade)
- [コレクション 5.5 Laravel](https://readouble.com/laravel/5.5/ja/collections.html)
- [php - How to manually create a new empty Eloquent Collection in Laravel 4 - Stack Overflow](https://stackoverflow.com/questions/23599584/how-to-manually-create-a-new-empty-eloquent-collection-in-laravel-4)
- [Controllers - Laravel - The PHP Framework For Web Artisans](https://laravel.com/docs/5.6/controllers)
- [HTTP Redirects - Laravel - The PHP Framework For Web Artisans](https://laravel.com/docs/5.6/redirects)
- [php - Laravel : Passing extra parameter on Collection filtering - Stack Overflow](https://stackoverflow.com/questions/24597499/laravel-passing-extra-parameter-on-collection-filtering)
- [PHP - [Laravel] Eloquentで配列をパラメータにしてSELECTしたい(3392)｜teratail](https://teratail.com/questions/3392)