Eloquentのローカルスコープを使わずに可読性・再利用性を高める方法
前書き
ローカルスコープを利用していると様々な課題が生じ、長い間解決策に悩んでおりました!
本記事では、現時点で良いと思っている方法を共有しようと思います。
ですので、ローカルスコープを使っており、以下に示すような課題をお持ちの方に読んで頂けると嬉しいです。
ローカルスコープの課題
- 複数のModelを跨ぐ処理はどこに書けば良いのか分からなくなる
- 可読性を上げるためだけにスコープに切り出したいが、共通化目的でないのにModelに書いて良いのか判断に迷う
- Fat Modelになりがち
- 静的解析で追えない
- 特定のドメインでのみ利用するクエリをModelに書くと、凝集度が低下する
newEloquentBuilderをオーバーライドする解決策でも、静的解析が効かない問題やFat Model化は回避できます。しかし、結局はModelとカスタムクエリビルダークラスは1対1の関係であり、カスタムククラスがFat化したり、複数のModelを跨ぐ処理をどのカスタムクラスに書くべきか判断に迷うという課題は解決できないと感じておりました。
ローカルスコープを利用する目的
- 切り出すことで再利用性を高めたい、共通化したい
- 関数に切り出して詳細を隠蔽することで、可読性を高めたい
結論
Builderを受け取って、Builderを返す関数を作成することで、上記のローカルスコープの課題点を解消しつつ、目的を達成できます!(以降はBuilder-Builder関数と呼びます)
ロジックを切り出した側
use Illuminate\Database\Eloquent\Builder;
class UserSearchBuilder
{
/**
*
* @param Builder<User> $query
* @param int $age
* @param string $city
* @return void
*/
public function byAgeAndCity(Builder $query, int $age, string $city): void
{
$query->where('age', '>=', $age)->where('city', $city);
}
}
使う側
$searchBuilder = new UserSearchBuilder();
$baseQuery=User::query();
$searchBuilder->byAgeAndCity($baseQuery, 20, 'Tokyo');
$users=$baseQuery->get();
上記の例ではbyAgeAndCity()の第一引数にUser::queryを指定しました。
ですので、$usersはUserテーブルに対して20歳以上&東京住まいの人という条件で絞り込んだ結果が入ります。
組み合わせる発展パターン
使う側
$searchBuilder = new UserSearchBuilder();
$baseQuery=User::query()->where('id','>=',100);
$queryBuilder->searchBy($baseQuery, 20, 'Tokyo');
$users=$baseQuery->get();
上記の例ではbyAgeAndCity()の第一引数にidが100以上で絞った状態のbuilderを渡しました。
ですので、$usersはUserテーブルに対してidが100以上&20歳以上&東京住まいの人という条件で絞り込んだ結果が入ります。
idが100以上というロジックを関数に切り出して、2つのBuilder-Builderを組み合わせてみます。
ロジックを切り出した側
use Illuminate\Database\Eloquent\Builder;
class UserSearchBuilder
{
public function byAgeAndCity(Builder $query, int $age, string $city): void
{
$query->where('age', '>=', $age)->where('city', $city);
}
public function byLowerLimitId(Builder $query, int $lowerLimitId): void
{
$query->where('id', '>=', $lowerLimitId);
}
}
使う側
$searchBuilder = new UserSearchBuilder();
$baseQuery=User::query();
$searchBuilder->byAgeAndCity($baseQuery, 20, 'Tokyo');
$searchBuilder->byLowerLimitId($baseQuery, 100);
$users=$baseQuery->get();
上記のcodeを読むと、なぜby*メソッドには返り値が存在しないのに、絞り込み条件が適用されるのか?などの疑問が生じると思います。
それには、クエリビルダのメソッドを繋げてget()を実行するまでの挙動のイメージを持つことが重要になります。
クエリビルダのメソッドを繋げて実行するまでのイメージ
クエリビルダのメソッドを繋げて、getメソッドを実行するまでの挙動をざっくり説明すると、
A. SQL文を組み立てるために、Builderクラスをインスタンス化する
B. Builderクラスが適切なSQL文を組み立てる (where,order,limitなどのメソッド)
C. 組み立て済みのSQL文を実行する (getメソッド)
実際のcodeで説明すると下記のようになります。②~④はSQLの構文をBuilderインスタンス内で組み立てているだけで、⑤を呼び出した段階でDBに対してSQL文を実行しているというイメージです。
- ①はA
- ②,③,④はB
- ⑤はC
$users=User::query //①
->where('id', '>=', 100)//②
->where('age', '>=', 20)//③
->where('city', 'Tokyo')//④
->get()//⑤;
DBクライアントツールを例に言い換えると、
- ①はDBクライアントツールを起動し、対象テーブルを指定
- ②,③,④は絞り込み条件を指定する
- ⑤は絞り込みを実行する
先程のcodeを改めて解説すると、
$searchBuilder = new UserSearchBuilder();
$baseQuery=User::query(); //①
$searchBuilder->byAgeAndCity($baseQuery, 20, 'Tokyo'); //②
$searchBuilder->byLowerLimitId($baseQuery, 100); //③
$users=$baseQuery->get(); //④
- ①はUserのクエリビルダインスタンスを作成(select * from users)
- ②と③は絞り込みのためのSQL文を組み立てる(select * from users where id >= 20 and …)
- ④は実行 (組み立て済みのSQL文を実行)
となります。
ポイントとしては、クエリビルダはgetメソッドを実行する直前まではSQL文を組み立てているだけであり、クエリビルダが保持しているSQL文はミュータブルに変化するということです。
//$baseBuilderが保持しているSQL文が2つのbyメソッドの実行内部でミュータブルに変化している
$searchBuilder->byAgeAndCity($baseQuery, 20, 'Tokyo');
$searchBuilder->byLowerLimitId($baseQuery, 100);
再利用しないが、可読性を上げるために切り出す場合
メソッド内部に詳細を隠蔽し、可読性を上げるためだけにModelにscopeを切り出し始めると、すぐにModelが肥大化してしまうので出来るなら避けたいと考えています。
Builder-Builderパターンを利用すれば、Modelの肥大化は抑えられますが、そのクエリでしか利用しないロジックを別ファイルに切り出すのも面倒くさいことがあります。
その場合は単にprivateメソッドへ切り出すのがおすすめです。
リファクタリング前
public function handle()
{
$users = App\User::query()
->where('age', '>=', 20)
->where(function ($query) {
$query->where('city', '=', 'New York')
->orWhere('city', '=', 'Los Angeles');
})
->when(request('role'), function ($query, $role) {
return $query->where('role', $role);
})
->whereHas('orders', function ($query) {
$query->where('status', 'completed')
->whereYear('created_at', '>=', now()->subYears(1)->year);
})
->whereColumn('updated_at', '>=', 'last_login_at')
->take(10)
->get();
}
実務では上記とは比べものにならない程クエリが複雑化し、一目で何が行われているのか分からなくなってしまう場合があると思います。
そのような場合は絞り込みの詳細をprivateメソッドに切り出すことで、handleメソッドを見た際に何をしているのかが分かりやすくするのが有効だと思っています。また、各privateメソッド内でdd($query->get())でデバッグが行えるのも嬉しい点だと感じています。
リファクタリング後
use App\Models\User;
use Illuminate\Database\Eloquent\Builder;
use Illuminate\Database\Eloquent\Collection;
public function handle() :Collection
{
$query = User::query();
$this->filterByAge($query);
$this->filterByCity($query);
$this->filterByRole($query);
$this->filterByOrderStatusAndYear($query);
$this->compareUpdateAndLastLogin($query);
$this->limitResults($query);
return $query->get();
}
private function filterByAge(Builder $query): void
{
$query->where('age', '>=', 20);
}
private function filterByCity(Builder $query): void
{
$query->where(function ($query) {
$query->where('city', '=', 'New York')
->orWhere('city', '=', 'Los Angeles');
});
}
private function filterByRole(Builder $query): void
{
$query->when(request('role'), function ($query, $role) {
$query->where('role', $role);
});
}
private function filterByOrderStatusAndYear(Builder $query): void
{
$query->whereHas('orders', function ($query) {
$query->where('status', 'completed')
->whereYear('created_at', '>=', now()->subYears(1)->year);
});
}
まとめ
builder-builder関数に切り出すことで静的解析も効くようになり、その上特定のドメインでのみ利用するクエリをEloquent Modelに書かなくて済むようにもなりました。
また、複雑なクエリを意味ある単位でprivateメソッドに切り出すことで、メイン処理側のクエリの可読性が上がるようになったかと思います。
クエリの可読性・再利用性を上げるための方法は色々あると思いますが、個人的にはBuilder-Builder関数に切り出すというやり方が一番しっくり来ています。
他の良いやり方を知っている方は、是非教えて頂けたら嬉しいです!
Twitterもしているので、フォローお願いいたします!
オンライン家庭教師マナリンクを運営するスタートアップNoSchoolのテックブログです。 manalink.jp/ 創業以来年次200%前後で売上成長しつつ、技術面・組織面での課題に日々向き合っています。 カジュアル面談はこちら! forms.gle/fGAk3vDqKv4Dg2MN7
Discussion