Laravel の Eloquent 検証(ローカルスコープ)

DB からデータを取得する時、様々な条件のもとに取得してくる事が多いと思います。 その度にwhereをいくつも連結して実装するのも、読み解くのも大変ですよね。

何が大変かと言うと、結局その実装で取得されるデータが何を表しているのか分かりにくい、というのが原因の1つなのではないかと感じました。

そういった事を解決してくれるクエリスコープという機能の1つであるローカルスコープについて検証しました。

参考:Eloquent: Getting Started - Laravel - The PHP Framework For Web Artisans



ローカルスコープの検証

マイグレーションファイルの準備

以下のような users テーブルを用意します。

  • is_active カラム:有効なユーザーかどうかを表すフラグ
  • role カラム(※ 例として以下のような分類とします)

    • admin:管理者
    • prime:一般よりも権限を持つユーザー
    • user:一般ユーザー
  • マイグレーションの定義

<?php

    public function up()
    {
        Schema::create('users', function (Blueprint $table) {
            $table->string('id', 36)->primary();
            $table->string('last_name');
            $table->string('first_name');
            $table->boolean('is_active');
            $table->enum('role', ['admin', 'prime', 'user']);
            $table->timestamps();
        });
    }

テストデータの準備

今回は、Fakerを使い、10人分のテストデータを生成しました。

Fakerはランダムなテストデータを生成するのに便利なライブラリです。 以下のサンプルのように人の名前だけでなく、email住所なども生成してくれます。

参考:[PHP] Fakerでランダムなフェイクデータを作成する - Qiita

<?php

$roles = ['prime', 'user'];
$faker = Factory::create('ja_JP');
foreach (range(1, 10) as $i) {
    // 最初の1人だけ admin にし、それ以外はランダムで prime か user ロール。
    $role = $i === 1 ? 'admin' : $roles[rand(0, 1)];
    User::create([
        'id' => $faker->uuid,
        'last_name' => $faker->lastName,
        'first_name' => $faker->firstName,
        'is_active' => $faker->boolean(),
        'role' => $role,
    ]);
}

生成したデータは以下の通りです。

f:id:yudy1152:20190506174824p:plain

検証1:シンプルな定義(有効なユーザーを取得する)

scopeプレフィックスとした function をモデルに定義する必要があります。 ただし、この function を呼び出す時は、scopeを付けません。

モデル定義

ここでは、有効状態のユーザーを操作する頻度が多い事を想定し、 以下のように User モデルにscopeActiveという名前で function を追加します。

<?php

namespace App\Models;

use Illuminate\Database\Eloquent\Builder;
use Illuminate\Database\Eloquent\Model;

class User extends Model
{
    〜 略 〜
    protected $casts = [
      'is_active' => 'boolean',
    ];

    public function scopeActive(Builder $query): Builder
    {
        return $query->where('is_active', true);
    }
}

実行

定義したスコープを使って件数を取得します(想定では8人)。

<?php
logger(User::Active()->count());

出力結果

[2019-05-06 08:59:09] testing.INFO: Query Time:15.99ms] select count(*) as aggregate from `users` where `is_active` = ?  
[2019-05-06 08:59:09] testing.INFO: array (
  0 => true,
)  
[2019-05-06 08:59:09] testing.DEBUG: 8  

発行されるクエリがis_activeを条件にしている事、カウントも適切な値が取得できた事を確認できました。

少しハマったポイント

※ ドキュメントにはUser::active()と、aが小文字で書かれており、これを実行すると以下のようなエラーが発生しました。

試しにAと大文字から書き始めたら正常に動くようになりました。

BadMethodCallException: Call to undefined method App\Models\User::acitve()

検証2:スコープに引数を渡せるようにする(動的スコープ)

モデル定義

roleprimeもしくはuserのリストを取得できるようにしたい、といったケースに対応できるようにするため、以下のようにscopeOfTypeという function を追加します。

<?php

namespace App\Models;

use Illuminate\Database\Eloquent\Builder;
use Illuminate\Database\Eloquent\Model;

class User extends Model
{
    〜 略 〜
    // $type には `admin`, `prime`, `user` のいずれかを指定します。
    public function scopeOfType(Builder $query, string $type): Builder
    {
        return $query->where('role', $type);
    }
}

実行

定義したスコープを使い、primeなユーザー数数を取得します(想定では3人)。

<?php
logger(User::OfType('prime')->count());

出力結果

[2019-05-06 09:21:46] testing.INFO: Query Time:20.1ms] select count(*) as aggregate from `users` where `role` = ?  
[2019-05-06 09:21:46] testing.INFO: array (
  0 => 'prime',
)  
[2019-05-06 09:21:46] testing.DEBUG: 3  

このように、roleカラムを対象にprimeで絞っており、結果が3である事も確認できました。

もちろん、User::OfType'user'を渡せば、roleを対象にuserで絞る事ができます。

<?php
logger(User::OfType('user')->count());
[2019-05-06 09:39:39] testing.INFO: Query Time:17.09ms] select count(*) as aggregate from `users` where `role` = ?  
[2019-05-06 09:39:39] testing.INFO: array (
  0 => 'user',
)  
[2019-05-06 09:39:39] testing.DEBUG: 6

検証3:作成したスコープの合わせ技

実行

作成した2つの function を合わせて使用し、アクティブでroleuserのユーザー数を取得します。(想定は4人)

合わせる時は単純に繋げて使用する事ができます。

<?php
logger(User::Active()->OfType('user')->count());

出力結果

[2019-05-06 09:43:51] testing.INFO: Query Time:17.5ms] select count(*) as aggregate from `users` where `is_active` = ? and `role` = ?  
[2019-05-06 09:43:51] testing.INFO: array (
  0 => true,
  1 => 'user',
)  
[2019-05-06 09:43:51] testing.DEBUG: 4 

is_activeおよびroleカラムを対象に絞り、結果が4である事を確認できました。

感想

スコープの名称(function 名)を適切なもので定義する事で、その実装がどういうデータを取得してこようとしているか、判断しやすくなるなと感じました。 むしろ判断しやすくなるようなスコープを定義する必要があるかもしれません。

また、例えばあるカラムの名前を変更する事になった場合、そのカラムを利用するクエリ(例:where('カラム名', $value))を、スコープの中に閉じ込めておくことで、ソースコードの修正も容易になるのかなと思いました。

スコープを積極的に使用して、メンテナンス性の高いモデルを実装していきたいなと思いました。