0
0

Delete article

Deleted articles cannot be recovered.

Draft of this article would be also deleted.

Are you sure you want to delete this article?

【Laravel】グローバルスコープで複雑なクエリを隠蔽してDRYなコードを実現する

0
Posted at

はじめに

Laravelで開発をしていると、「このモデルを呼び出すときは、いつも特定のJOINやWHERE句を付けたい」という場面に出くわしませんか?

例えば、

  • 有効なユーザー(is_active = true)だけを取得したい
  • 複数のテーブルをJOINした結果を、あたかも1つのテーブルかのように扱いたい

こうした要件をコントローラに毎回書いていると、コードが冗長になり、メンテナンスも大変です。
今回は、この悩みをエレガントに解決するLaravelの強力な機能、グローバルスコープについて、具体的なコードを交えて解説します。


グローバルスコープとは?

グローバルスコープとは、特定のモデルに対して、クエリが実行されるたびに自動的に特定の条件を追加する仕組みです。

最も身近な例は、Laravelに標準で備わっている**論理削除(SoftDeletes)**です。
use SoftDeletes; とモデルに記述するだけで、そのモデルに対する全てのクエリに自動で WHERE deleted_at IS NULL という条件が追加されます。これにより、開発者は削除済みのレコードを意識することなく、有効なレコードだけを扱えます。

グローバルスコープを使えば、これと同じような「モデルに対するお決まりの制約」を自分で定義できます。


JOIN結果を「仮想テーブル」として扱う

今回は、purchases, items, customers などの複数テーブルをJOINした購入詳細データを、あたかもOrderという単一のモデル(仮想的なテーブル)であるかのように扱う、非常に強力な実装例を見ていきます。

STEP 1: Scopeクラスを作成する

まず、クエリの具体的なロジックを記述するScopeクラスを作成します。
このクラスはIlluminate\Database\Eloquent\Scopeインターフェースを実装し、applyメソッドを持つ必要があります。

// app/Models/Scopes/Subtotal.php

namespace App\Models\Scopes;

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

class Subtotal implements Scope
{
    public function apply(Builder $builder, Model $model)
    {
        // 4つのテーブルをJOINし、各項目と小計(subtotal)を計算するSQL文
        $sql = 'select purchases.id as id
            , items.price * item_purchase.quantity as subtotal
            , customers.name as customer_name
            , items.name as item_name
            , item_purchase.quantity
            , purchases.status
            , purchases.created_at
            from purchases
            left join item_purchase on purchases.id = item_purchase.purchase_id
            left join items on item_purchase.item_id = items.id
            left join customers on purchases.customer_id = customers.id
        ';

        // ★最重要ポイント
        // このSQLの実行結果を'order_subtotals'という名前の
        // 一時的なテーブル(サブクエリ)として扱うように設定
        $builder->fromSub($sql, 'order_subtotals');
    }
}

このコードの核心は$builder->fromSub($sql, 'order_subtotals')です。

これはEloquentに対して、「ordersという物理的なテーブルを直接見に行かないで代わりに、この$sqlを実行した結果をorder_subtotalsという名前の一時テーブル(サブクエリ)と見なして、それに対して後続のクエリを組み立ててね」と指示しています。
これにより、複雑なJOINの結果を一つのシンプルなテーブルのように見せかけることができます。

STEP 2: モデルにScopeを登録する

次に、作成したSubtotalスコープをOrderモデルに登録します。
モデルのbootedメソッド内でaddGlobalScopeを呼び出すのが標準的な方法です。

// app/Models/Order.php

namespace App\Models;

use Illuminate\Database\Eloquent\Factories\HasFactory;
use Illuminate\Database\Eloquent\Model;
use App\Models\Scopes\Subtotal; // 作成したScopeをuse

class Order extends Model
{
    use HasFactory;

    // モデルが起動するときに自動で実行されるメソッド
    protected static function booted()
    {
        // このモデルにSubtotalスコープを常時適用する
        static::addGlobalScope(new Subtotal);
    }
}

Orderモデルにはordersという物理的なテーブルは存在しないかもしれません。このモデルは、先ほどのSubtotalスコープによって定義された仮想的な存在として機能します。

STEP 3: コントローラから呼び出す

準備は完了です。コントローラからOrderモデルを呼び出してみましょう。

// app/Http/Controllers/PurchaseController.php

public function index()
{
    // Orderモデルを呼び出すだけで、裏では自動的にSubtotalスコープが適用される
    $orders = Order::groupBy('id', 'customer_name', 'status', 'created_at')
        ->selectRaw('id, sum(subtotal) as total, customer_name, status, created_at')
        ->paginate(50);

    return Inertia::render('Purchases/Index', [
        'orders' => $orders
    ]);
}

✨ このコードの凄さ
コントローラ側では、purchasesitemsテーブルの存在を一切意識していません。ただOrderモデルに対して、あたかもsubtotalcustomer_nameというカラムが最初から存在しているかのようにクエリを組み立てるだけです。

舞台裏で起きていること

  1. Order::...が呼び出される。
  2. Orderモデルのbootedメソッドが実行され、Subtotalスコープが適用される。
  3. Subtotalスコープのapplyメソッドが実行され、クエリのFROM句がFROM ( [あの長いJOIN文] ) as order_subtotalsに置き換わる。
  4. その上で、コントローラに書かれたgroupByselectRawが追加される。

結果として、非常に複雑なSQLが実行されますが、開発者はその複雑さから完全に解放されます。


グローバルスコープのメリットまとめ

  • DRY (Don't Repeat Yourself): 同じような複雑なクエリを何度も書く必要がなくなります。
  • 関心の分離: データベースの複雑な構造に関するロジックをモデル層に閉じ込める(カプセル化する)ことができます。コントローラは本来の責務に集中できます。
  • 可読性の向上: コントローラ側のコードが非常にシンプルになり、何をしているのかが一目瞭然になります。

補足:Scope内で使われているSQLクエリの解説 📜

グローバルスコープの心臓部であるSQLクエリが、具体的に何をしているのかを分解して見ていきましょう。このクエリの目的は、バラバラのテーブルに保存されている情報を一つにまとめ上げ、扱いやすい形に整形することです。

select
    -- どのテーブルのどの情報を取得するかを指定
    purchases.id as id,
    items.price * item_purchase.quantity as subtotal, -- ★小計を計算
    customers.name as customer_name,
    items.name as item_name,
    item_purchase.quantity,
    purchases.status,
    purchases.created_at
from
    purchases -- ① 主役となる「購入」テーブル
left join
    item_purchase on purchases.id = item_purchase.purchase_id -- ② 購入と品目を繋ぐ中間テーブル
left join
    items on item_purchase.item_id = items.id -- ③ 品目の詳細(名前や価格)テーブル
left join
    customers on purchases.customer_id = customers.id -- ④ 購入者の情報テーブル

クエリが目指すゴール

このクエリが目指すのは、「誰が(customers)、いつ(purchases)、何を(items)、いくつ(item_purchase)購入し、その品目ごとの小計(subtotal)はいくらか」という情報を、一枚の表として取得することです。

各テーブルの役割と結合

  • from purchases (主役)
    全ての情報の起点となるpurchases(購入履歴)テーブルを主役に設定します。

  • left join item_purchase ... (購入と品目)
    purchasesテーブルとitem_purchaseテーブルをpurchases.iditem_purchase.purchase_idで紐付けます。item_purchaseは中間テーブルと呼ばれ、「どの購入にどの品目が含まれるか」を管理しています。

  • left join items ... (品目詳細)
    次に、item_purchaseが持つitem_idをキーにしてitems(品目マスター)テーブルを結合し、品目名(name)や単価(price)といった詳細情報を取得します。

  • left join customers ... (購入者情報)
    最後に、purchasesが持つcustomer_idをキーにしてcustomers(顧客マスター)テーブルを結合し、購入者の名前(name)を取得します。

SELECT句のポイント

  • items.price * item_purchase.quantity as subtotal
    ここがこのクエリの肝です。品目マスターの単価と、中間テーブルの数量を掛け合わせることで、品目ごとの小計subtotalという名前の新しいカラムとしてその場で算出しています。

  • as ...
    asを使って、purchases.ididcustomers.namecustomer_nameのように分かりやすい別名(エイリアス)をつけています。これにより、後のOrderモデルやコントローラから直感的にデータを扱えるようになります。

このように、正規化されて複数のテーブルに分かれているデータをJOINで組み合わせ、動的に計算も加えることで、アプリケーションにとって非常に価値のある「仮想的なテーブル」を作り出しているのです。
image.png

↑ 結果として上記のようなテーブルを取得できます。

おわりに

グローバルスコープは、モデルの振る舞いを定義し、複雑なクエリを裏側に隠蔽するための非常に強力なツールです。
「このモデルには、常にこの条件が必要だ」と感じたら、ぜひグローバルスコープの活用を検討してみてください。コードがよりクリーンで、メンテナンスしやすいものになるはずです。

0
0
0

Register as a new user and use Qiita more conveniently

  1. You get articles that match your needs
  2. You can efficiently read back useful information
  3. You can use dark theme
What you can do with signing up
0
0

Delete article

Deleted articles cannot be recovered.

Draft of this article would be also deleted.

Are you sure you want to delete this article?