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に沿ってマイグレーションを編集する。
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');
});
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');
});
各モデルに主キーを設定する。
class Bug extends Model
{
protected $primaryKey = 'bug_id';
}
class Comment extends Model
{
protected $primaryKey = 'comment_id';
}
class TreePath extends Model
{
protected $primaryKey = ['ancestor', 'descendant'];
public $incrementing = false;
}
マイグレーションを流してテーブルを作る。
php artisan migrate
ルートを生成する
Route::resource
がだいたいRailsでいうroutes
のresources
。
今回必要なのは/bugs/show
と/comments/store
だけだが、大雑把に生成したコントローラのルーティングを設定する。
Route::resource('bugs', 'BugController');
Route::resource('comments', 'CommentController');
テンプレートを編集する
バグの詳細画面を作る
まずバグの詳細画面が表示されるようにする。
何もしなくても$bug
変数に対象のIDのBugレコードが入っているので、それをそのままテンプレートに割り当てる。
少々面倒くさいのは、Railsと違ってビューに割り当てる変数を全部コントローラからビューに渡してやる必要があるところ。
:
public function show(Bug $bug)
{
return view('bugs.show', compact('bug'));
}
:
<!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
にアクセスする。
するとこんな感じで表示される。
コメントの投稿画面を作る
次にツリーコメントを実装していく。
テンプレートにフォームを作る。
:
</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>
するとこんな感じの画面になる。
次にCommentController
を編集して投稿したコメントがDBに入るようにする。
:
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コンソールからクエリを発行する等してコメントが入っている事を確認する。
コメントをツリー表示する
いよいよコメントのツリー表示をする。
:
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
)を作成している。
:
<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>
ツリーにするために再帰的にテンプレートを使う。
<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>
親テンプレートの変数は子テンプレートにも引き継がれる模様。
するとこんな感じの表示になる(適当にコメントを投稿しつつ動作確認)。
備考
書籍中では、すべてのコメントにancestor == descendant
なtree_pathes
レコードができているが、それは今回の実装では端折っている。
もうちょっといいやり方があるかも知れない。
参考
- SQLアンチパターン
- php - Laravel 5.2 : How to get a variable in a subView of a parentView from another subView? - Stack Overflow
- Blade Templates - Laravel - The PHP Framework For Web Artisans
- コレクション 5.5 Laravel
- php - How to manually create a new empty Eloquent Collection in Laravel 4 - Stack Overflow
- Controllers - Laravel - The PHP Framework For Web Artisans
- HTTP Redirects - Laravel - The PHP Framework For Web Artisans
- php - Laravel : Passing extra parameter on Collection filtering - Stack Overflow
- PHP - [Laravel] Eloquentで配列をパラメータにしてSELECTしたい(3392)|teratail