実務1年目駆け出しエンジニアがLaravel&ReactでWebアプリケーション開発に挑戦してみた!(その45)
0. 初めに
こんにちは!
今日もWebアプリケーション開発を解説していきます!
GWはどうにか、宣言通り毎日記事を更新することができました。
前回から本格的にUnitテストのテストケースを作成しておりました。
今回は、ポリシーに続いて、モデルのテストケースの作成に取り組みたいと思います!
1. ブランチ運用
developブランチを最新化させて、新規ブランチをそこから切って作業を始めましょう。
ブランチ名は、test/unit/modelsなどとしておきましょう。
2. ユーザーモデル
ユーザーモデルのメソッドをテストします。
\project-root\src\tests\Unit配下にModelsというフォルダを作り、その下に新しくUserTest.phpというファイルを作りましょう。
内容は以下のようにしてみてください。
<?php
namespace Tests\Unit\Models;
use App\Models\User;
use PHPUnit\Framework\TestCase;
class UserTest extends TestCase
{
public function test_管理者フラグがtrueの場合(): void
{
// Arrange
$user = new User();
$user->is_admin = true;
// Act
$result = $user->is_admin();
// Assert
$this->assertTrue($result);
}
public function test_管理者フラグがfalseの場合(): void
{
// Arrange
$user = new User();
$user->is_admin = false;
// Act
$result = $user->is_admin();
// Assert
$this->assertFalse($result);
}
}
今回もAAAパターンを採用しております!
できたら確認してみましょう。
実行コマンド
$ php artisan test --filter=UserTest
3. 研究室モデル
<?php
namespace Tests\Unit\Models;
use App\Models\Lab;
use App\Models\Review;
use PHPUnit\Framework\TestCase;
class LabTest extends TestCase
{
// --- getAveragePerItem ---
public function test_各評価項目の平均値を正しく計算できる(): void
{
// Arrange
$lab = new Lab();
$lab->setRelation('reviews', collect([
new Review([
'mentorship_style' => 3,
'lab_atmosphere' => 4,
'achievement_activity' => 5,
'constraint_level' => 2,
'facility_quality' => 4,
'work_style' => 3,
'student_balance' => 4,
]),
new Review([
'mentorship_style' => 5,
'lab_atmosphere' => 2,
'achievement_activity' => 3,
'constraint_level' => 4,
'facility_quality' => 2,
'work_style' => 5,
'student_balance' => 2,
]),
]));
// Act
$result = $lab->getAveragePerItem();
// Assert
$this->assertEquals(4.0, $result['mentorship_style']);
$this->assertEquals(3.0, $result['lab_atmosphere']);
$this->assertEquals(4.0, $result['achievement_activity']);
$this->assertEquals(3.0, $result['constraint_level']);
$this->assertEquals(3.0, $result['facility_quality']);
$this->assertEquals(4.0, $result['work_style']);
$this->assertEquals(3.0, $result['student_balance']);
}
public function test_レビューが1件の場合はその値がそのまま返る(): void
{
// Arrange
$lab = new Lab();
$lab->setRelation('reviews', collect([
new Review([
'mentorship_style' => 3,
'lab_atmosphere' => 4,
'achievement_activity' => 5,
'constraint_level' => 2,
'facility_quality' => 4,
'work_style' => 3,
'student_balance' => 4,
]),
]));
// Act
$result = $lab->getAveragePerItem();
// Assert
$this->assertEquals(3.0, $result['mentorship_style']);
$this->assertEquals(4.0, $result['lab_atmosphere']);
$this->assertEquals(5.0, $result['achievement_activity']);
$this->assertEquals(2.0, $result['constraint_level']);
$this->assertEquals(4.0, $result['facility_quality']);
$this->assertEquals(3.0, $result['work_style']);
$this->assertEquals(4.0, $result['student_balance']);
}
public function test_レビューが0件の場合はnullが返る(): void
{
// Arrange
$lab = new Lab();
$lab->setRelation('reviews', collect([]));
// Act
$result = $lab->getAveragePerItem();
// Assert
foreach (Lab::RATING_COLUMNS as $column) {
$this->assertNull($result[$column]);
}
}
// --- getOverallAverage ---
public function test_全項目の総合平均値を正しく計算できる(): void
{
// Arrange
$lab = new Lab();
$lab->setRelation('reviews', collect([
new Review([
'mentorship_style' => 2,
'lab_atmosphere' => 3,
'achievement_activity' => 5,
'constraint_level' => 4,
'facility_quality' => 5,
'work_style' => 5,
'student_balance' => 4,
]),
new Review([
'mentorship_style' => 5,
'lab_atmosphere' => 4,
'achievement_activity' => 3,
'constraint_level' => 5,
'facility_quality' => 3,
'work_style' => 4,
'student_balance' => 4,
]),
]));
// Act
$result = $lab->getOverallAverage();
// Assert
// 各項目平均: 3.5, 3.5, 4.0, 4.5, 4.0, 4.5, 4.0 → 総合平均 = 4.0
$this->assertEquals(4.0, $result);
}
public function test_レビューが0件の場合の総合平均はnullが返る(): void
{
// Arrange
$lab = new Lab();
$lab->setRelation('reviews', collect([]));
// Act
$result = $lab->getOverallAverage();
// Assert
$this->assertNull($result);
}
// --- getUserReviewAverage ---
public function test_特定レビューの全項目平均を正しく計算できる(): void
{
// Arrange
$review = new Review([
'mentorship_style' => 3,
'lab_atmosphere' => 4,
'achievement_activity' => 5,
'constraint_level' => 2,
'facility_quality' => 4,
'work_style' => 3,
'student_balance' => 4,
]);
// Act
$result = Lab::getUserReviewAverage($review);
// Assert
// (3+4+5+2+4+3+4) / 7 ≒ 3.571...
$this->assertEquals(3.571, $result);
}
// --- appendRatingAverages ---
public function test_動的属性が正しくセットされる(): void
{
// Arrange
$lab = new Lab();
$lab->setRelation('reviews', collect([
new Review([
'mentorship_style' => 3,
'lab_atmosphere' => 4,
'achievement_activity' => 5,
'constraint_level' => 2,
'facility_quality' => 4,
'work_style' => 3,
'student_balance' => 4,
]),
new Review([
'mentorship_style' => 5,
'lab_atmosphere' => 2,
'achievement_activity' => 3,
'constraint_level' => 4,
'facility_quality' => 2,
'work_style' => 5,
'student_balance' => 2,
]),
]));
// Act
$result = $lab->appendRatingAverages();
// Assert
// 各項目平均: 4.0, 3.0, 4.0, 3.0, 3.0, 4.0, 3.0 → 総合平均 ≒ 3.429
$this->assertEquals(3.429, $result->overall_avg);
$this->assertEquals(4.0, $result->avg_mentorship_style);
$this->assertEquals(3.0, $result->avg_lab_atmosphere);
$this->assertEquals(4.0, $result->avg_achievement_activity);
$this->assertEquals(3.0, $result->avg_constraint_level);
$this->assertEquals(3.0, $result->avg_facility_quality);
$this->assertEquals(4.0, $result->avg_work_style);
$this->assertEquals(3.0, $result->avg_student_balance);
$this->assertEquals(2, $result->reviews_count);
}
}
これは少し複雑なので、一つずつ見ていきましょう。
3.1 getAveragePerItem
例によって、リレーション系のメソッドは飛ばして、研究室モデルに特有のレビューの評価値計算系のメソッドを順々にテストしていきます。
最初は、このgetAveragePerItemメソッドです。
/**
* 各評価項目のユーザー間の平均値を取得する
* reviews リレーションがロード済みである前提
*/
public function getAveragePerItem(): Collection
{
return collect(self::RATING_COLUMNS)->mapWithKeys(function ($column) {
return [$column => $this->reviews->avg($column)];
});
}
例の「Aクラスの全生徒の英語の平均点、数学の平均点、国語の平均点...」を求める計算ですね。
test_各評価項目の平均値を正しく計算できる()を見てみましょう。
まずは、AAAパターンに従って、Arrange(準備)ブロックを作成しています。
// Arrange
$lab = new Lab();
$lab->setRelation('reviews', collect([
new Review([
'mentorship_style' => 3,
'lab_atmosphere' => 4,
'achievement_activity' => 5,
'constraint_level' => 2,
'facility_quality' => 4,
'work_style' => 3,
'student_balance' => 4,
]),
new Review([
'mentorship_style' => 5,
'lab_atmosphere' => 2,
'achievement_activity' => 3,
'constraint_level' => 4,
'facility_quality' => 2,
'work_style' => 5,
'student_balance' => 2,
]),
]));
研究室モデルをインスタンス化してsetRelationメソッドでリレーションを定義しています。
注意したいのは、あくまでDB操作をせずにリレーションを組んでいる点です。
リレーション自体のテストはしていないことに気を付けましょう。
その中で二つの異なる評価値を持ったレビューインスタンスを用意してコレクションとして渡しています。
なぜ二つも必要なのかと言いますと、当然ながら平均値を計算するためです。
次に、Act(実行)ブロックです。
メソッドを呼び出し、結果を$resultに格納します。
// Act
$result = $lab->getAveragePerItem();
最後に、Assert(検証)です。
// Assert
$this->assertEquals(4.0, $result['mentorship_style']);
$this->assertEquals(3.0, $result['lab_atmosphere']);
$this->assertEquals(4.0, $result['achievement_activity']);
$this->assertEquals(3.0, $result['constraint_level']);
$this->assertEquals(3.0, $result['facility_quality']);
$this->assertEquals(4.0, $result['work_style']);
$this->assertEquals(3.0, $result['student_balance']);
assertEqualsは第一引数と第二引数が等しい時に成功とみなすメソッドですね。
例えば、'mentorship_style'は(5 + 3) ÷ 2 = 4となるのが正しいので、期待される結果として4.0を第一引数に渡しています。
一方で、実際の結果である$result['mentorship_style']は第二引数に渡しています。
つまり、実際の具体的な数字で計算したものとメソッドによる計算結果が一致するかどうかを確認しているということですね!
他の評価指標についても同様です。
二つ目のtest_レビューが1件の場合はその値がそのまま返る()では、レビューが複数ないときは平均値は当然同じ値になる(Aクラスに太郎さん一人しかいない場合太郎さんの点数がそのままAクラスの平均点数になる)ことを確認しているだけです。
public function test_レビューが1件の場合はその値がそのまま返る(): void
{
// Arrange
$lab = new Lab();
$lab->setRelation('reviews', collect([
new Review([
'mentorship_style' => 3,
'lab_atmosphere' => 4,
'achievement_activity' => 5,
'constraint_level' => 2,
'facility_quality' => 4,
'work_style' => 3,
'student_balance' => 4,
]),
]));
// Act
$result = $lab->getAveragePerItem();
// Assert
$this->assertEquals(3.0, $result['mentorship_style']);
$this->assertEquals(4.0, $result['lab_atmosphere']);
$this->assertEquals(5.0, $result['achievement_activity']);
$this->assertEquals(2.0, $result['constraint_level']);
$this->assertEquals(4.0, $result['facility_quality']);
$this->assertEquals(3.0, $result['work_style']);
$this->assertEquals(4.0, $result['student_balance']);
}
また、レビューがない時は何も返らないことも確認します。
public function test_レビューが0件の場合はnullが返る(): void
{
// Arrange
$lab = new Lab();
$lab->setRelation('reviews', collect([]));
// Act
$result = $lab->getAveragePerItem();
// Assert
foreach (Lab::RATING_COLUMNS as $column) {
$this->assertNull($result[$column]);
}
}
assertNullは引数がnullなら成功とみなすメソッドですね。
また、::はスコープ定義演算子と呼ばれ、インスタンス化せずともクラスのプロパティに静的にアクセスできるもので、本シリーズでも何度か登場していたはずです。
モデルに定義されている定数をプロパティとして呼び出しています。
public const RATING_COLUMNS = [
'mentorship_style',
'lab_atmosphere',
'achievement_activity',
'constraint_level',
'facility_quality',
'work_style',
'student_balance',
];
3.2 getOverallAverage
次に、getOverallAverageに関するテストを見てみましょう。
モデルに定義されていたメソッドは以下の通りです。
/**
* 全評価項目の総合平均値を取得する
*/
public function getOverallAverage(): ?float
{
return $this->getAveragePerItem()->avg();
}
各評価項目のさらに平均値を求めます。
いわば、「Aクラスの英語、数学、国語の3科目の平均点を求めることで、それがAクラスの総合学力とみなす」ようなイメージです。
テストケースは以下のようにしました。
public function test_全項目の総合平均値を正しく計算できる(): void
{
// Arrange
$lab = new Lab();
$lab->setRelation('reviews', collect([
new Review([
'mentorship_style' => 2,
'lab_atmosphere' => 3,
'achievement_activity' => 5,
'constraint_level' => 4,
'facility_quality' => 5,
'work_style' => 5,
'student_balance' => 4,
]),
new Review([
'mentorship_style' => 5,
'lab_atmosphere' => 4,
'achievement_activity' => 3,
'constraint_level' => 5,
'facility_quality' => 3,
'work_style' => 4,
'student_balance' => 4,
]),
]));
// Act
$result = $lab->getOverallAverage();
// Assert
// 各項目平均: 3.5, 3.5, 4.0, 4.5, 4.0, 4.5, 4.0 → 総合平均 = 4.0
$this->assertEquals(4.0, $result);
}
public function test_レビューが0件の場合の総合平均はnullが返る(): void
{
// Arrange
$lab = new Lab();
$lab->setRelation('reviews', collect([]));
// Act
$result = $lab->getOverallAverage();
// Assert
$this->assertNull($result);
}
3.3 getUserReviewAverage
続いて、getUserReviewAverageです。
/**
* 特定レビューの全評価項目の平均値を取得する
*/
public static function getUserReviewAverage(Review $review): ?float
{
return collect(self::RATING_COLUMNS)
->map(fn($column) => $review->$column)
->filter(fn($value) => $value !== null)
->avg();
}
これは、「太郎さん一人の、英語、数学、国語の3科目の平均点を求めることで、太郎さんの総合学力値とする」イメージですね。
public function test_特定レビューの全項目平均を正しく計算できる(): void
{
// Arrange
$review = new Review([
'mentorship_style' => 3,
'lab_atmosphere' => 4,
'achievement_activity' => 5,
'constraint_level' => 2,
'facility_quality' => 4,
'work_style' => 3,
'student_balance' => 4,
]);
// Act
$result = Lab::getUserReviewAverage($review);
// Assert
// (3+4+5+2+4+3+4) / 7 ≒ 3.571...
$this->assertEqualsWithDelta(3.571, $result, 0.001);
}
ただし、このままだとテストが通りません(後で解説します)。
3.4 appendRatingAverages
最後は、appendRatingAveragesですね。
scopeWithRatingAveragesメソッドはクエリビルダに関わるものなので、Featureテストに回します。
/**
* overall_avg, avg_{col}, reviews_count を動的属性としてセットする
* MyPageController 等でコレクション内の各 Lab に付与する用途
*/
public function appendRatingAverages(): self
{
$averagePerItem = $this->getAveragePerItem();
$this->overall_avg = $averagePerItem->avg();
foreach (self::RATING_COLUMNS as $column) {
$this->{"avg_{$column}"} = $averagePerItem[$column];
}
$this->reviews_count = $this->reviews->count();
return $this;
}
こちらは、平均値だけでなく、「テストの受験者数」も付与できるメソッドです。
これを使うことで、「Aクラスの男子の平均点と男子の人数を求め」たりすることができるようになります(たとえが分かりにくくなってきているのは気のせいだと思いたいところです...)。
public function test_動的属性が正しくセットされる(): void
{
// Arrange
$lab = new Lab();
$lab->setRelation('reviews', collect([
new Review([
'mentorship_style' => 3,
'lab_atmosphere' => 4,
'achievement_activity' => 5,
'constraint_level' => 2,
'facility_quality' => 4,
'work_style' => 3,
'student_balance' => 4,
]),
new Review([
'mentorship_style' => 5,
'lab_atmosphere' => 2,
'achievement_activity' => 3,
'constraint_level' => 4,
'facility_quality' => 2,
'work_style' => 5,
'student_balance' => 2,
]),
]));
// Act
$result = $lab->appendRatingAverages();
// Assert
// 各項目平均: 4.0, 3.0, 4.0, 3.0, 3.0, 4.0, 3.0 → 総合平均 ≒ 3.429
$this->assertEquals(3.429, $result->overall_avg, 0.001);
$this->assertEquals(4.0, $result->avg_mentorship_style);
$this->assertEquals(3.0, $result->avg_lab_atmosphere);
$this->assertEquals(4.0, $result->avg_achievement_activity);
$this->assertEquals(3.0, $result->avg_constraint_level);
$this->assertEquals(3.0, $result->avg_facility_quality);
$this->assertEquals(4.0, $result->avg_work_style);
$this->assertEquals(3.0, $result->avg_student_balance);
$this->assertEquals(2, $result->reviews_count);
}
お気づきかもしれませんが、実はこれも通りません。
3.5 実行結果
実行コマンド
$ php artisan test --filter=LabTest
7つのテストケースのうち、2つが失敗しています。
3.6 デバッグ(小数点以下の丸め込み処理追加)
小数点以下が結果に表れるときですね。
これまで、平均値を計算する処理について小数点以下の丸め込み処理(何桁目を四捨五入するのか・切り捨てるのかなど)を全く考えていませんでした。
正確には以下のJSファイル(フロントエンド側)で行っていました。
/**
* 評価値を小数点以下2桁にフォーマット
* @param {number|null} value - 評価値
* @param {string} fallback - 値がnullの場合の代替文字列(デフォルト: null)
* @returns {string|null} フォーマットされた評価値
*/
export const formatRating = (value, fallback = null) => {
return value != null ? Number(value).toFixed(2) : fallback;
};
バックエンドからフロントエンドにデータを送るときに割り切れずにとんでもない桁数になる可能性もありそうで怖いので、念のためバックエンドの方でも丸め込みをしておくように修正するのがベターかなと思います。
今後「画面側で3桁まで表示したい」とか「1桁だけでよいペーも作りたい」みたいな要望が出てくるかもしれないということで、フロントエンドでのフォーマット処理はそのまま残しておきます。
バックエンドでは、小数点以下4桁目を四捨五入して3桁の値を返すように修正します。
/**
* 各評価項目のユーザー間の平均値を取得する
* reviews リレーションがロード済みである前提
*/
public function getAveragePerItem(): Collection
{
return collect(self::RATING_COLUMNS)->mapWithKeys(function ($column) {
$avg = $this->reviews->avg($column);
return [$column => $avg !== null ? round($avg, 3) : null];
});
}
/**
* 全評価項目の総合平均値を取得する
*/
public function getOverallAverage(): ?float
{
$avg = $this->getAveragePerItem()->avg();
return $avg !== null ? round($avg, 3) : null;
}
/**
* 特定レビューの全評価項目の平均値を取得する
*/
public static function getUserReviewAverage(Review $review): ?float
{
$avg = collect(self::RATING_COLUMNS)
->map(fn($column) => $review->$column)
->filter(fn($value) => $value !== null)
->avg();
return $avg !== null ? round($avg, 3) : null;
}
/**
* overall_avg, avg_{col}, reviews_count を動的属性としてセットする
* MyPageController 等でコレクション内の各 Lab に付与する用途
*/
public function appendRatingAverages(): self
{
$averagePerItem = $this->getAveragePerItem();
$avg = $averagePerItem->avg();
$this->overall_avg = $avg !== null ? round($avg, 3) : null;
foreach (self::RATING_COLUMNS as $column) {
$this->{"avg_{$column}"} = $averagePerItem[$column];
}
$this->reviews_count = $this->reviews->count();
return $this;
}
/**
* 一覧表示用: 各評価項目の平均・総合評価・レビュー数をクエリに付与するスコープ
*/
public function scopeWithRatingAverages(Builder $query): Builder
{
$query->select('labs.*')->withCount('reviews');
foreach (self::RATING_COLUMNS as $column) {
$query->addSelect([
"avg_{$column}" => Review::query()
->selectRaw("ROUND(AVG($column), 3)")
->whereColumn('reviews.lab_id', 'labs.id'),
]);
}
$avgSum = implode(' + ', array_map(fn($c) => "ROUND(AVG($c), 3)", self::RATING_COLUMNS));
$count = count(self::RATING_COLUMNS);
$query->addSelect([
'overall_avg' => Review::query()
->selectRaw("ROUND(($avgSum) / $count, 3)")
->whereColumn('reviews.lab_id', 'labs.id'),
]);
return $query;
}
ブランチは分けてもよかったのですが、テストを書いて初めて発覚したということもありこのままのブランチでコミットしましょう。
分かりやすくするために以下のようにして修正のコミットとテストコード追加のコミットは分けて二つにしましょう。
$ git add app/Models/Lab.php
これでいったんコミットします。
3.7 再テスト実行
テストケースの方は最初から小数点以下3桁で作っていたので修正は必要ありません。
作成したテストコードもコミットしてみましょう。
$ git add tests/Unit/Models/
できたら、プッシュして、プルリクエストを作成・マージ、ブランチの削除までしてしまいましょう!
4. デバッグ(モデルメソッドの型注釈)
今日の作業としては異常なのですが、もう少しだけやっていきましょうか。
これはバグではないので、厳密にはデバッグではなくリファクタリングに該当すると思いますが、モデルのメソッドのテストを書いていて気が付いたので修正したいと思います。
それが、型注釈がされていない点です。
以前、コントローラーのメソッド定義には型注釈をつけていましたが、モデルには付けていませんでした。
良い機会ですので、可読性高以上のためにここで付けておきましょう!
4.1 ブランチ運用
先ほどの修正をdevelopブランチに取り込んで、新規にrefactor/backend/model-method-typeというブランチを切って作業開始です。
不要になった、test/unit/modelsブランチは削除しましょう。
4.2 ユーザーモデル
まずは、ユーザーモデルからです。
ついでにPHPDoc風のコメントアウトも付与しましょう。
例えば、こんな感じです!
/**
* 管理者かどうかを判定するメソッド
*/
public function is_admin(): bool
{
return $this->is_admin;
}
キャメルケースに直したくて気持ち悪いのですw
(is_admin => isAdmin)
次回以降時間があれば直すかもです...(笑)
全体のコードはこんな感じです。
<?php
namespace App\Models;
use Illuminate\Database\Eloquent\Factories\HasFactory;
use Illuminate\Database\Eloquent\Relations\BelongsToMany;
use Illuminate\Database\Eloquent\Relations\HasMany;
use Illuminate\Database\Eloquent\Relations\MorphMany;
use Illuminate\Database\Eloquent\SoftDeletes;
use Illuminate\Foundation\Auth\User as Authenticatable;
use Illuminate\Notifications\DatabaseNotification;
use Illuminate\Notifications\Notifiable;
class User extends Authenticatable
{
use HasFactory, Notifiable, SoftDeletes;
/**
* The attributes that are mass assignable.
*
* @var array<int, string>
*/
protected $fillable = [
'name',
'email',
'password',
'is_admin', // 管理者フラグ
];
/**
* The attributes that should be hidden for serialization.
*
* @var array<int, string>
*/
protected $hidden = [
'password',
'remember_token',
];
/**
* Get the attributes that should be cast.
*
* @return array<string, string>
*/
protected function casts(): array
{
return [
'email_verified_at' => 'datetime',
'password' => 'hashed',
];
}
/**
* 管理者かどうかを判定するメソッド
*/
public function is_admin(): bool
{
return $this->is_admin;
}
/**
* リレーションの定義
*
* 大学とのリレーション(多対多)
* 中間テーブル名を明示的に指定
*/
public function universities(): BelongsToMany
{
return $this->belongsToMany(University::class, 'university_edit_histories')->withTimestamps();
}
/**
* 学部とのリレーション(多対多)
*
* 中間テーブル名を明示的に指定
*/
public function faculties(): BelongsToMany
{
return $this->belongsToMany(Faculty::class, 'faculty_edit_histories')->withTimestamps();
}
/**
* 研究室とのリレーション(多対多)
*
* 中間テーブル名を明示的に指定
*/
public function labs(): BelongsToMany
{
return $this->belongsToMany(Lab::class, 'lab_edit_histories')->withTimestamps();
}
/**
* レビューとのリレーション(一対多)
*/
public function reviews(): HasMany
{
return $this->hasMany(Review::class);
}
/**
* コメントとのリレーション(一対多)
*/
public function comments(): HasMany
{
return $this->hasMany(Comment::class);
}
/**
* ブックマークとのリレーション(一対多)
*/
public function bookmarks(): HasMany
{
return $this->hasMany(Bookmark::class);
}
/**
* 通知とのリレーション(一対多)
*/
public function notifications(): MorphMany
{
return $this->morphMany(DatabaseNotification::class, 'notifiable')->latest();
}
}
4.3 大学モデル
<?php
namespace App\Models;
use Illuminate\Database\Eloquent\Model;
use Illuminate\Database\Eloquent\Factories\HasFactory;
use Illuminate\Database\Eloquent\Relations\BelongsTo;
use Illuminate\Database\Eloquent\Relations\BelongsToMany;
use Illuminate\Database\Eloquent\Relations\HasMany;
use Illuminate\Database\Eloquent\Relations\MorphMany;
use Illuminate\Database\Eloquent\SoftDeletes;
class University extends Model
{
use HasFactory, SoftDeletes;
protected $fillable = [
'name',
'type',
];
/**
* 論理削除時に関連する学部も論理削除するように設定
*/
protected static function boot(): void
{
parent::boot();
static::deleting(function ($university) {
// 論理削除時に関連する学部も論理削除
$university->faculties()->get()->each->delete();
});
}
// リレーションの定義
/**
* ユーザーとのリレーション(多対多)
*
* 中間テーブル名を明示的に指定
*/
public function users(): BelongsToMany
{
return $this->belongsToMany(User::class, 'university_edit_histories')->withTimestamps();
}
/**
* 学部とのリレーション(一対多)
*/
public function faculties(): HasMany
{
return $this->hasMany(Faculty::class);
}
/**
* 削除依頼とのポリモーフィックリレーション(一対多)
*/
public function deletionRequests(): MorphMany
{
return $this->morphMany(DeletionRequest::class, 'target');
}
/**
* 作成者とのリレーション(多対一)
*/
public function creator(): BelongsTo
{
return $this->belongsTo(User::class, 'created_by');
}
}
4.4 学部モデル
<?php
namespace App\Models;
use Illuminate\Database\Eloquent\Model;
use Illuminate\Database\Eloquent\Factories\HasFactory;
use Illuminate\Database\Eloquent\Relations\BelongsTo;
use Illuminate\Database\Eloquent\Relations\BelongsToMany;
use Illuminate\Database\Eloquent\Relations\HasMany;
use Illuminate\Database\Eloquent\Relations\MorphMany;
use Illuminate\Database\Eloquent\SoftDeletes;
class Faculty extends Model
{
use HasFactory, SoftDeletes;
protected $fillable = [
'name', 'university_id'
];
/**
* 論理削除時に関連する研究室も論理削除するように設定
*/
protected static function boot(): void
{
parent::boot();
static::deleting(function ($faculty) {
// 論理削除時に関連する研究室も論理削除
$faculty->labs()->get()->each->delete();
});
}
// リレーションの定義
/**
* ユーザーとのリレーション(多対多)
*
* 中間テーブル名を明示的に指定
*/
public function users(): BelongsToMany
{
return $this->belongsToMany(User::class, 'faculty_edit_histories')->withTimestamps();
}
/**
* 大学とのリレーション(多対一)
*/
public function university(): BelongsTo
{
return $this->belongsTo(University::class);
}
/**
* 研究室とのリレーション(一対多)
*/
public function labs(): HasMany
{
return $this->hasMany(Lab::class);
}
/**
* 削除依頼とのポリモーフィックリレーション(一対多)
*/
public function deletionRequests(): MorphMany
{
return $this->morphMany(DeletionRequest::class, 'target');
}
/**
* 作成者とのリレーション(多対一)
*/
public function creator(): BelongsTo
{
return $this->belongsTo(User::class, 'created_by');
}
}
4.6 レビューモデル
<?php
namespace App\Models;
use Illuminate\Database\Eloquent\Builder;
use Illuminate\Database\Eloquent\Model;
use Illuminate\Database\Eloquent\Factories\HasFactory;
use Illuminate\Database\Eloquent\Relations\BelongsTo;
use Illuminate\Database\Eloquent\Relations\BelongsToMany;
use Illuminate\Database\Eloquent\Relations\HasMany;
use Illuminate\Database\Eloquent\Relations\MorphMany;
use Illuminate\Database\Eloquent\SoftDeletes;
use Illuminate\Support\Collection;
class Lab extends Model
{
use HasFactory, SoftDeletes;
public const RATING_COLUMNS = [
'mentorship_style',
'lab_atmosphere',
'achievement_activity',
'constraint_level',
'facility_quality',
'work_style',
'student_balance',
];
protected $fillable = [
'name',
'faculty_id',
];
// リレーションの定義
/**
* ユーザーとのリレーション(多対多)
*
* 中間テーブル名を明示的に指定
*/
public function users(): BelongsToMany
{
return $this->belongsToMany(User::class, 'lab_edit_histories')->withTimestamps();
}
/**
* 学部とのリレーション(多対一)
*/
public function faculty(): BelongsTo
{
return $this->belongsTo(Faculty::class);
}
/**
* レビューとのリレーション(一対多)
*/
public function reviews(): HasMany
{
return $this->hasMany(Review::class);
}
/**
* コメントとのリレーション(一対多)
*/
public function comments(): HasMany
{
return $this->hasMany(Comment::class);
}
/**
* ブックマークとのリレーション(一対多)
*/
public function bookmarks(): HasMany
{
return $this->hasMany(Bookmark::class);
}
/**
* 削除依頼とのポリモーフィックリレーション(一対多)
*/
public function deletionRequests(): MorphMany
{
return $this->morphMany(DeletionRequest::class, 'target');
}
/**
* 作成者とのリレーション(多対一)
*/
public function creator(): BelongsTo
{
return $this->belongsTo(User::class, 'created_by');
}
/**
* 各評価項目のユーザー間の平均値を取得する
* reviews リレーションがロード済みである前提
*/
public function getAveragePerItem(): Collection
{
return collect(self::RATING_COLUMNS)->mapWithKeys(function ($column) {
$avg = $this->reviews->avg($column);
return [$column => $avg !== null ? round($avg, 3) : null];
});
}
/**
* 全評価項目の総合平均値を取得する
*/
public function getOverallAverage(): ?float
{
$avg = $this->getAveragePerItem()->avg();
return $avg !== null ? round($avg, 3) : null;
}
/**
* 特定レビューの全評価項目の平均値を取得する
*/
public static function getUserReviewAverage(Review $review): ?float
{
$avg = collect(self::RATING_COLUMNS)
->map(fn($column) => $review->$column)
->filter(fn($value) => $value !== null)
->avg();
return $avg !== null ? round($avg, 3) : null;
}
/**
* overall_avg, avg_{col}, reviews_count を動的属性としてセットする
* MyPageController 等でコレクション内の各 Lab に付与する用途
*/
public function appendRatingAverages(): self
{
$averagePerItem = $this->getAveragePerItem();
$avg = $averagePerItem->avg();
$this->overall_avg = $avg !== null ? round($avg, 3) : null;
foreach (self::RATING_COLUMNS as $column) {
$this->{"avg_{$column}"} = $averagePerItem[$column];
}
$this->reviews_count = $this->reviews->count();
return $this;
}
/**
* 一覧表示用: 各評価項目の平均・総合評価・レビュー数をクエリに付与するスコープ
*/
public function scopeWithRatingAverages(Builder $query): Builder
{
$query->select('labs.*')->withCount('reviews');
foreach (self::RATING_COLUMNS as $column) {
$query->addSelect([
"avg_{$column}" => Review::query()
->selectRaw("ROUND(AVG($column), 3)")
->whereColumn('reviews.lab_id', 'labs.id'),
]);
}
$avgSum = implode(' + ', array_map(fn($c) => "ROUND(AVG($c), 3)", self::RATING_COLUMNS));
$count = count(self::RATING_COLUMNS);
$query->addSelect([
'overall_avg' => Review::query()
->selectRaw("ROUND(($avgSum) / $count, 3)")
->whereColumn('reviews.lab_id', 'labs.id'),
]);
return $query;
}
}
4.7 ブックマークモデル
<?php
namespace App\Models;
use Illuminate\Database\Eloquent\Factories\HasFactory;
use Illuminate\Database\Eloquent\Model;
use Illuminate\Database\Eloquent\Relations\BelongsTo;
class Bookmark extends Model
{
use HasFactory;
protected $fillable = [
'user_id',
'lab_id',
];
// リレーション定義
/**
* ユーザーとのリレーション(多対一)
*/
public function user(): BelongsTo
{
return $this->belongsTo(User::class);
}
/**
* 研究室とのリレーション(多対一)
*/
public function lab(): BelongsTo
{
return $this->belongsTo(Lab::class);
}
}
4.8 コメントモデル
<?php
namespace App\Models;
use Illuminate\Database\Eloquent\Model;
use Illuminate\Database\Eloquent\Relations\BelongsTo;
class Comment extends Model
{
protected $fillable = [
'user_id',
'lab_id',
'content',
];
// リレーションの定義
/**
* ユーザーとのリレーション(多対一)
*/
public function user(): BelongsTo
{
return $this->belongsTo(User::class);
}
/**
* 研究室とのリレーション(多対一)
*/
public function lab(): BelongsTo
{
return $this->belongsTo(Lab::class);
}
}
4.9 削除依頼モデル
<?php
namespace App\Models;
use Illuminate\Database\Eloquent\Model;
use Illuminate\Database\Eloquent\Relations\BelongsTo;
use Illuminate\Database\Eloquent\Relations\MorphTo;
class DeletionRequest extends Model
{
protected $fillable = [
'requested_by',
'processed_by',
'target_id',
'target_type',
'status',
'reason',
'processed_at',
];
protected $casts = [
'processed_at' => 'datetime',
];
// リレーションの定義
/**
* 削除対象モデルとのポリモーフィックリレーション(多対一)
*/
public function target(): MorphTo
{
return $this->morphTo();
}
/**
* 依頼者とのリレーション(多対一)
*/
public function requester(): BelongsTo
{
return $this->belongsTo(User::class, 'requested_by');
}
/**
* 対応する管理者とのリレーション(多対一)
*/
public function processor(): BelongsTo
{
return $this->belongsTo(User::class, 'processed_by');
}
}
できたら、コミット・プッシュ、PR作成・マージ、ブランチ削除を忘れずに行っておきましょう!
5. まとめ・次回予告
お疲れ様でした!
今日は、Uniteテスト3日目ということで、モデルのメソッドに対するテストケースを作成しました!
これでいったUnitテストは完了です!
次回からはFeatureテストに取り組みたいと思います。
お楽しみに~
これまでの記事一覧
☆要件定義・設計編
☆環境構築編
☆バックエンド実装編
- その7: バックエンド実装編① ~認証機能作成~
- その8: バックエンド実装編②前編 ~レビュー投稿機能作成~
- その8.5: バックエンド実装編②後編 ~レビュー投稿機能作成~
- その9: バックエンド実装編③ ~レビューCRUD機能作成~
- その10: バックエンド実装編④ ~レビューCRUD機能作成その2~
- その11: バックエンド実装編⑤ ~新規大学・学部・研究室作成機能作成~
- その12: バックエンド実装編⑥ ~大学検索機能作成~
- その13: バックエンド実装編⑦ ~大学・学部・研究室編集機能作成~
- その14: バックエンド実装編⑧ ~コメント投稿機能~
- その15: バックエンド実装編⑨ ~コメント編集・削除機能~
- その16: バックエンド実装編⑩ ~ブックマーク機能~
- その17: バックエンド実装編⑪ ~排他制御・トランザクション処理~
- その18: バックエンド実装編⑫ ~マイページ機能作成~
- その19: バックエンド実装編⑬ ~管理者アカウント機能作成~
- その20: バックエンド実装編⑭ ~通知機能作成~
- その21: バックエンド実装編⑮ ~ソーシャルログイン機能作成~
☆フロントエンド実装編
- その22: フロントエンド実装編① ~メインコンテンツ領域作成~
- その23: フロントエンド実装編② ~ヘッダー作成~
- その24: フロントエンド実装編③ ~サイドバー作成~
- その25: フロントエンド実装編④ ~ホームページ作成~
- その26: フロントエンド実装編⑤ ~大学検索結果画面作成~
- その27: フロントエンド実装編⑥ ~大学詳細・学部一覧画面作成~
- その28: フロントエンド実装編⑦ ~学部詳細・研究室一覧画面作成~
- その29: フロントエンド実装編⑧前編 ~研究室詳細・レビュー画面作成前編~
- その29.5: フロントエンド実装編⑧後編 ~研究室詳細・レビュー画面作成後編~
- その30: フロントエンド実装編⑨ ~マイページ作成~
- その31: フロントエンド実装編⑩ ~パンくずリスト作成~
- その32: フロントエンド実装編⑪ ~ログイン・新規登録モーダル作成~
- その33: フロントエンド実装編⑫ ~大学作成・編集モーダル作成~
- その34: フロントエンド実装編⑬ ~学部作成・編集モーダル作成~
- その35: フロントエンド実装編⑭ ~研究室作成・編集モーダル作成~
- その36: フロントエンド実装編⑮ ~レビュー作成・編集モーダル作成~
- その37: フロントエンド実装編⑯ ~コメント一覧表示モーダル作成~
- その38: フロントエンド実装編⑰ ~編集履歴ページ・削除依頼ページ・通知ドロップダウン作成~
- その39: フロントエンド実装編⑱ ~トースト作成~
- その40: フロントエンド実装編⑲ ~退会ページ作成~
- その41: フロントエンド実装編⑳ ~ファビコン作成~
- その42: フロントエンド実装編㉑ ~レスポンシブデザイン対応~
☆テスト・デバッグ編
軽く宣伝
YouTubeを始めました(というか始めてました)。
内容としては、Webエンジニアの生活や稼げるようになるまでの成長記録などを発信していく予定です。
現在、まったく再生されておらず、落ち込みそうなので、見てくださる方はぜひ高評価を教えもらえると励みになると思います。"(-""-)"





