Laravel 演習入門

ゲーテがすべてを言った

Relationship の基礎

問題

以下のような、ゲーテの名言 API を作成してください。


問題は、以下の手順で解いてください。

  1. Red:小さいテストを作成し、失敗を確認してください
  2. Green:テストを成功させてください
  3. Refactor:整理・整頓してください
  4. 必要に応じて、1から3を繰り返してください

ヒント

背景知識
便利なアサーションの例

アサーションの調べ方 も合わせてご覧ください。
今回は、以下を使うのではないかと思います。

解答例

続きを読む

実行環境:

以下の手順で作成していきます。

  1. 初期名言データ個数と、Quote::all() のデータの個数が同じか
  2. 初期名言データ個数と、goethe と結びついた quotes データの個数が同じか
  3. /quotes/goethe にアクセスして得られた JSON 内に、初期名言データの最初の名言が含まれているか

(前準備)Inspiring::quotes()->count() で名言の数を数えておく

テストを作成します。
bash:

php artisan make:test EverythingGoetheTest

tests/Feature/EverythingGoetheTest.php:

<?php

namespace Tests\Feature;

use Illuminate\Foundation\Testing\RefreshDatabase;
use Tests\TestCase;
use Illuminate\Foundation\Inspiring;

class EverythingGoetheTest extends TestCase
{
    public function test_名言の数を数える(): void
    {
        $count = Inspiring::quotes()->count();
        var_dump($count);
        $this->assertTrue(true);
    }
}

私の環境では、 int(41) という数字が表示されました。
以下、この数字に一致するかを確認していきます。

Red1: 初期名言データ個数と、Quote::all() のデータの個数が同じか

テストを編集します。
tests/Feature/EverythingGoetheTest.php:

<?php

namespace Tests\Feature;

use Illuminate\Foundation\Testing\RefreshDatabase;
use Tests\TestCase;
use App\Models\Quote;

class EverythingGoetheTest extends TestCase
{
    use RefreshDatabase;
    public function test_quote_allの数と同じか(): void
    {
        $this->seed();

        $count = Quote::all()->count();
        $this->assertSame(41, $count);
    }
}

失敗を確認します。

Green1

Quote モデル及びマイグレーションファイル、シーダーファイル、ファクトリーファイルを作成します。
bash:

php artisan make:model Quote -fms

マイグレーションファイルを編集します。
database/migrations/…create_quotes_table.php:

    public function up(): void
    {
        Schema::create('quotes', function (Blueprint $table) {
            $table->id();
            $table->text('quote');
            $table->foreignId('user_id')->constrained()->onDelete('cascade');
            $table->timestamps();
        });
    }

シーダーファイルを編集します。
database/seeders/QuotesSeeder.php:

<?php

namespace Database\Seeders;

use Illuminate\Database\Console\Seeds\WithoutModelEvents;
use Illuminate\Database\Seeder;
use Illuminate\Foundation\Inspiring;
use App\Models\Quote;

class QuoteSeeder extends Seeder
{
    public function run(): void
    {
        Inspiring::quotes()->each(function (string $quote_author): void {
            $quote_string = trim(explode('-', $quote_author)[0]);
            Quote::factory()->create([
                'quote' => $quote_string,
            ]);
        });
    }
}

database/seeders/DatabaseSeeder.php:

<?php

namespace Database\Seeders;

use App\Models\User;
use Illuminate\Database\Console\Seeds\WithoutModelEvents;
use Illuminate\Database\Seeder;

class DatabaseSeeder extends Seeder
{
    use WithoutModelEvents;

    public function run(): void
    {
        // 以下は元からあったものを goethe に書き換えました
        User::factory()->create([
            'name' => 'goethe',
            'email' => 'goethe@example.com',
        ]);

        $this->call([
            QuoteSeeder::class,
        ]);
    }
}

ファクトリーファイルを編集します。 database/factorories/

<?php

namespace Database\Factories;

use App\Models\Quote;
use Illuminate\Database\Eloquent\Factories\Factory;

/**
 * @extends Factory<Quote>
 */
class QuoteFactory extends Factory
{
    /**
     * Define the model's default state.
     *
     * @return array<string, mixed>
     */
    public function definition(): array
    {
        return [
            'quote' => '名言',
            'user_id' => 1,  // goethe ユーザーは、最初に登録するので、 user_id は 1
        ];
    }
}

テストの成功を確認します。

Refactor1

特にありません。

Red2: 初期名言データ個数と、goethe と結びついた quotes データの個数が同じか

テストを追加します。
tests/Feature/EverythingGoetheTest.php:

<?php

namespace Tests\Feature;

use Illuminate\Foundation\Testing\RefreshDatabase;
use Tests\TestCase;
use App\Models\User;

class EverythingGoetheTest extends TestCase
{
    use RefreshDatabase;
    public function test_goetheの名言数と同じか(): void
    {
        $this->seed();

        $goethe = User::where('name', 'goethe')->first();
        $count = $goethe->quotes()->count();
        $this->assertSame(41, $count);
    }
}

失敗を確認します。

Green2

User モデルに、 Quote モデルを紐づけます。
app/Models/User.php:

use Illuminate\Database\Eloquent\Relations\HasMany;
...
    public function quotes(): HasMany
    {
        return $this->hasMany(Quote::class);
    }

なお、 Quote モデルと User モデルは、同じ namespace なので、use の必要がありません。

この時点で、テストは成功します。

Refactor2

テストは成功するのですが、 User 側から紐づいていて、 Quote 側から紐づいていないのは、誤解のもとなので、 Quote 側も編集しておきます。
app/Models/Quote.php

<?php

namespace App\Models;

use Illuminate\Database\Eloquent\Factories\HasFactory;
use Illuminate\Database\Eloquent\Model;
use Illuminate\Database\Eloquent\Relations\BelongsTo;

class Quote extends Model
{
    /** @use HasFactory<\Database\Factories\QuoteFactory> */
    use HasFactory;

    public function user(): BelongsTo
    {
        return $this->belongsTo(User::class);
    }
}

ユーザー側からは、 quotes (複数形)ですが、名言側からは user (単数形)であることに注意してください。
1人のユーザーに複数の名言が紐づく可能性がありますが、1つの名言には必ず1人のユーザーしか紐づかないので、このような命名規則になっているようです。

Red3: /quotes/goethe にアクセスして得られた JSON 内に、初期名言データの最初の名言が含まれているか

テストを追加します。
tests/Feature/EverythingGoetheTest.php:

    public function test_quotes_goetheにアクセスして名言が表示されるか(): void
    {
        $this->seed();

        $response = $this->get('/quotes/goethe');
        // これは、名言リストの最初の1つです。どれを選んでも OK です。
        $quote = 'Act only according to that maxim whereby you can, at the same time, will that it should become a universal law.';

        $response->assertStatus(200)  // アクセスできるか
            ->assertJson([])          // json 形式か
            ->assertJsonCount(41, 'quotes')  // 個数が正しいか
            ->assertSeeText($quote);  // 名言があるか
    }

失敗を確認します。

Green3

routing を作成します。
route/web.php:

use App\Http\Controllers\GoetheApiController;
Route::get('/quotes/goethe', [GoetheApiController::class, 'index']);

コントローラーを作成します。
bash:

php artisan make:controller GoetheApiController

app/Http/Controllers/GoetheApiController.php:

<?php

namespace App\Http\Controllers;

use App\Models\User;

class GoetheApiController extends Controller
{
    public function index(): array
    {
        $goethe = User::where('name', 'goethe')->first();
        return [
            'quotes' => $goethe->quotes,
        ];
    }
}

Refactor3

特にありません。

解説

続きを読む

リレーションとは

データを保存するとき、伝統的にテーブル(表)形式で保存する方法が採用され、重複を省く(正規化する)ために、テーブルどうしのリレーションという方法が生まれました。

それを管理してくれるのが、リレーショナル・データベース管理システム(RDBMS)で、 MySql や、 PostgreSQL などが有名です。
私の環境では、 SQLite3 を使っています。

リレーショナル・データベースは、 SQL という言語でコードを書くのですが、 SQL の世界は、ちょっと特殊なことに、データを集合として管理します。
おそらく内部的には、 B+木などのデータ構造を、手続き的に処理しているはずですが、 SQL では、それが抽象化されて集合的に扱えるようになっています。

詳しい内容は、書籍が何冊も出ているので、それらを読んでみることをおすすめします。
ミックさんという方の本が、難易度別にいろいろあって、オススメです。

Eloquent とは

大変ややこしいのですが、抽象化されている SQL を、もう一段階抽象化したものが、 ORM というもののようです。

どうしてこんな面倒なことになっているのか、歴史的経緯は分かりませんが、 ORM では、集合的な SQL を、オブジェクトとして利用できるように(つまりメソッドなどが使えるように)抽象化しています。
そして、 Laravel で採用している ORM が、 Eloquent です。

この二段階構造のため、パフォーマンス・チューニング(データ処理が遅い場合の最適化)では、2箇所の抽象化を調べることになります。

ORM が適切に SQL に変換されているかは、 dump() メソッドなどを使います。
その後、 SQL が適切に内部のデータ構造を処理しているかは、「実行計画」というものを調べます。

多くの場合、 Laravel で問題になるのは N+1 問題などの ORM => SQL の変換部分であるため、まずは前者を気にしておく必要があります。

Eloquent でのリレーション

今回の問題で見たように、 Eloquent では、テーブルどうしのリレーションを、 Model class で定義します。

など、それぞれ関係性を定義できるようになっています。

本来、テーブルで表現しにくい階層構造のデータであっても、リレーションを適切に設定することで、 RDBMS がものすごいスピードでデータを取得してくれます。

テーブルの設計および、それらの利用方法は、ある意味バックエンドエンジニアの真骨頂となる部分でしょうから、ぜひ詳しくなりたいものです。


<= 問題を読んだ・解いた・理解したなどのチェックにご利用ください。クリックすると、チェックが変化します。
問題一覧に戻る