Laravelで複雑なリレーションシップの検索をシンプルにする

こんにちは!フリーランス・エンジニアの 九保すこひ です。

さてさて、私はLaravelを使ったウェブ開発をはじめてから5年以上の時間が経っています。

そして、ありがたいことに基本的に何か新しい機能を開発する場合でも「うーん、コレはどうやったら実現できるのかわからないぞ・・・💦とは」みたいなことって少なくなってきました(ホント、Stack Overflowや先人の公開した情報のおかげです。感謝です😊✨)

ただ、そんな状況であってもこの間、「うーーーーーん・・・」が発生したことがありました。

それは、例えば、以下のようなデータベースの検索です。

  • hasManyで結合した子テーブルにはデータが入っていないもの(ただし、バナナはカウントから除外する)を取得する
  • hasManyした子テーブルの「name」が「イチゴ」だけのもの(ただし、パイナップルはカウントから除外する)を取得する

当初は、whereHas('リレーションシップ名', function(){ })を使ってリレーションシップ先の検索を試してみましたが、最終的にうまくいきませんでした。(もしかしたら達人にかかればサクッと自前のSQLを作っちゃうかもですが・・・)

さらに検索なので、できるだけ高速にSQLが実行できるようにしたいこともあってwhereHasはあまり現実的ではないような気になっていました・・・

そこで!

今回はこのようなリレーションシップ先の複雑な条件をどのように実現したかをご紹介したいと思います。

ぜひ皆さんのお役に立てると嬉しいです😊✨

開発環境: Laravel 6.x

どのようにこの問題を解決したか?

SQLだけのやり方はあきらめた

まず先に行ってしまうと、SQLだけでの実現は諦めました。

そして、別のアプローチの方法を考えていたのですが、その時に役に立ったのが「時間がかかることは先にやっとこう」精神でした。

つまり、複雑な検索部分は先に集計しておいて事前に親テーブルにstatusとして保存しておくことにしたのです。

例えば、絵を使って見てみましょう。

※ この例では、箱が親テーブルで、その下のフルーツがhasManyした子テーブルです。

この場合、IDが1の箱は、バナナしか入っていないので冒頭で紹介した条件「データが入っていないもの(ただし、バナナはカウントから除外する)」に当てはまります。

これをプログラムを使って事前にチェックしておいて、例えばno_dataのようなステータスを親テーブルに保存しておけば、1つのテーブルをチェックするだけで複雑な検索が実現できますし、さらに、より高速な検索を実現することができるという仕組みです。

どのタイミングで子テーブルを集計するのか?

そして、次に考えたのは、「その子テーブルの集計」をいつすればいいのかということでした。

答えは、ズバリ「子テーブルに変更があったとき」です。

例えば、いま新しくイチゴ🍓のデータを追加するとします。

子テーブルはhasManyで結合されるので必ず親テーブルのIDを持っているはずです。そして、この親IDを持っている子テーブルのデータ全てを取り出して、その状態がどうなっているかをチェックしておけばいいわけです。

↓↓↓例はこんな感じです。

🍓 🍍 🍓 🍍 🍍 = only_strawberry

※ パイナップルは除外するという条件なのでイチゴだけになっています。

これができれば、後は以下のようにwhere()whereIn()を使ってシンプルに検索ができるようになります。

$box = \App\Box::where('status', 'only_strawberry')->get();

Laraveではどうすればいいか

では、この「先にやっとこう」作戦を実際にLaravelで実現するにはどうすればいいかを順を追ってみていきましょう。

テーブルとテストデータを用意する

先にコードを実行するためのテーブルをテストデータをDBに用意します。ただし、もしすでにこの構造を作っている方は次の項目まで読み飛ばしてください。

まず、以下のコマンドでモデルとマイグレーションを作成します。

php artisan make:model Box -m
php artisan make:model BoxDetail -m

そして、作成された各マイグレーションのup()を次のようにします。

(database/migrations/****_**_**_******_create_boxes_table.php)

public function up()
{
    Schema::create('boxes', function (Blueprint $table) {
        $table->bigIncrements('id');
        $table->string('name');
        $table->string('status')->default('no_data'); // ここに集計した結果がはいる
        $table->timestamps();
    });
}

(database/migrations/****_**_**_******_create_box_details_table.php)

public function up()
{
    Schema::create('box_details', function (Blueprint $table) {
        $table->bigIncrements('id');
        $table->unsignedBigInteger('box_id');
        $table->string('name');
        $table->timestamps();

        $table->foreign('box_id')->references('id')->on('boxes');
    });
}

次にテストデータのためのSeederを作ります。
以下のコマンドを実行してください。(Seederは1つだけで実装します)

php artisan make:seed BoxesTableSeeder

そして、database/seeds/BoxesTableSeeder.phpを開いて以下のように変更します。

public function run()
{
    for($i = 0 ; $i < 10 ; $i++) {

        $box = new \App\Box();
        $box->name = '箱 - ' . $i;
        $box->save();

        for($j = 0 ; $j < 10 ; $j++) {

            $box_detail = new \App\BoxDetail();
            $box_detail->box_id = $box->id;
            $box_detail->name = Arr::random(['バナナ', 'イチゴ', 'リンゴ', 'もも', 'ぶどう']);
            $box_detail->save();

        }

    }
}

そして、このSeederdatabase/seeds/DatabaseSeeder.phpに登録して実行すれば完了です。

public function run()
{
     //$this->call(UsersTableSeeder::class);
     $this->call(BoxesTableSeeder::class);
}

(コマンドを実行)

php artisan migrate:fresh --seed

これで10件の箱データと、100件の詳細データが作成されました。

Savedイベントをつくる

先ほどの「どのタイミングで子テーブルを集計するのか?」で説明したとおり、子テーブルの集計は子テーブルに何らかの変更があった(もっと言うとnameに変更があった)ときになります。

これを実現するためにDBにデータが保存されたら実行されるSavedイベントをBoxDetailモデルにつけておきましょう。

まずは以下のコマンドを実行してください。

php artisan make:event BoxDetailsSaved

そして、app/Events/BoxDetailsSaved.phpを以下のように変更します。

<?php

namespace App\Events;

use App\BoxDetail;
use Illuminate\Broadcasting\Channel;
use Illuminate\Broadcasting\InteractsWithSockets;
use Illuminate\Broadcasting\PresenceChannel;
use Illuminate\Broadcasting\PrivateChannel;
use Illuminate\Contracts\Broadcasting\ShouldBroadcast;
use Illuminate\Foundation\Events\Dispatchable;
use Illuminate\Queue\SerializesModels;

class BoxDetailsSaved
{
    use Dispatchable, InteractsWithSockets, SerializesModels;

    /**
     * Create a new event instance.
     *
     * @return void
     */
    public function __construct(BoxDetail $current_detail)
    {
        // 親データ
        $box = \App\Box::find($current_detail->box_id);

        // 保存したデータの親IDをもつデータを全て取得
        $details = \App\BoxDetail::where('box_id', $current_detail->box_id)->pluck('name');

        // 重複するデータを削除
        $unique_details = $details->unique();

        $status = 'no_data';

        if($unique_details->count() === 1 && $unique_details->first() === 'バナナ') {  // バナナだけの場合

            // バナナは除外するのでデータなしとします
            $status = 'no_data';

        }

        // 省略(その他も "else if" でステータスを変更)

        // 親データのステータスを保存
        $box->status = $status;
        $box->save();

    }

    /**
     * Get the channels the event should broadcast on.
     *
     * @return \Illuminate\Broadcasting\Channel|array
     */
    public function broadcastOn()
    {
        return new PrivateChannel('channel-name');
    }
}

また、イベントは作成するだけでは実行されませんのでBoxDetailモデルに以下のように登録しておきます。

<?php

namespace App;

use App\Events\BoxDetailsSaved;
use Illuminate\Database\Eloquent\Model;

class BoxDetail extends Model
{
    protected $dispatchesEvents = [
        'saved' => BoxDetailsSaved::class
    ];
}

※ 注意: Laravel 5.4までは$dispatchesEventsは、$eventsという名前でした。

これで、子テーブルが保存されたら自動的に内容を集計して、親データのステータスを更新してくれるようになりました。

お疲れ様でした😊✨

ちなみに

イベントをつけたときにたまに問題になってくるのですが、イベントの中で保存することによって、連鎖的にさらに別の(もしくは同じ)イベントが実行されてしまったりするケースがあります。

もしこのようなことを防ぐためにはwithoutEvents()を使って一時的にイベントを外して実行するといいでしょう。

\App\User::withoutEvents(function(){

    $user = new \App\User();
    // 省略
    $user->save();

});

※ ただし、withoutEvents()はバージョン5.7.26から追加されたもので、それ以前は以下のようにunsetEventDispatcher()を使ってイベントを回避していました。

\App\User::unsetEventDispatcher();

$user = new \App\User();
// 省略
$user->save();

また、独自artisanコマンドでデータ保存&savedイベント実行のときに、グローバルスコープを使っていてうまくいかないということがあります。(コマンドで実行するとログインしてないのでユーザーIDがとれなかったり、そもそもユーザーID限定にはしない目的だったりするわけですね😂)

そんな場合は、以下のようにしてグローバルスコープも使わないようにするといいでしょう。

\App\User::withoutGlobalScope(LoginUserScope::class)->get();

おわりに

ということで、今回はいつもの基本的な内容ではなく基本テクニックを寄せ集めた応用的な内容をお届けしました。

やはり基本がしっかりしていないと応用はできないということを再認識させられました。これからも基本を大事にして「正しい努力」を続けていきたいと思います😊

ただ、それにしても開発者として気になるのは「今回の複雑な条件をSQLだけで実現できるのかどうか??」です。

もし、SQL一発で実行可能な方法を知っていたら、ぜひお問い合わせフォームから教えてください!ここの記事で追記としてお名前&リンクと一緒に紹介させていただきます。

お待ちしています。

ではでは〜!

この記事が役立ちましたらシェアお願いします😊✨ by 九保すこひ
また、わかりにくい部分がありましたらお問い合わせからお気軽にご連絡ください。
このエントリーをはてなブックマークに追加       follow us in feedly