💡

Laravel 検索条件からクエリー生成を実装してみる

2021/10/05に公開

こちらの記事内容と関わっており、開放・閉鎖の原則の実践でもあります。

とあるプロジェクトでこのような検索機能を実装しています。

検索項目はいくつかありますが、必須条件がありません。

ファーストトライ

初めてこの要求を聞いたときに、感覚的にif文の連打で行けると思いました。多少複雑になるでしょうが、チェック入れた項目によって、対応するQueryを作れば良いと。

実際にやってみれば確かにできなくはないですが、汚いです。

private function filterQuery(Request $request, object $query, bool $is_factory = false)
    {
        if ($is_factory && ($request->filled('a_synced') ||
            $request->filled('b_synced') ||
            $request->filled('c_synced'))) {
            return collect();
        }

        if ($request->filled('item_name')) {
            $name = $request->input('item_name');
            $field = $is_factory ? 'items_factories.name' : 'items.name';
            $query = $query->where($field, 'LIKE', '%' . $name . '%');
        }
        if ($request->filled('code')) {
            $field = $is_factory ? 'items_factories.cd' : 'items.cd';
            $query = $query->where($field, $request->input('cd'));
        }
        if (
            $request->filled('status_a') ||
            $request->filled('status_b') ||
            $request->filled('status_c') ||
            $request->filled('status_d')
        ) {
            $status = [];
            if ($request->filled('status_a')) {
                array_push($status, 'a');
            }
            if ($request->filled('status_b')) {
                array_push($status, 'b');
            }
            if ($request->filled('status_c')) {
                array_push($status, 'c');
            }
            if ($request->filled('status_d')) {
                array_push($status, 'd');
            }
            $query = $query->whereIn('status', $status);
        }
        if (
            $request->filled('a_synced') ||
            $request->filled('b_synced') ||
            $request->filled('c_synced')
        ) {
            $query = $query->where(function ($q) use ($request) {
                if ($request->filled('a_synced')) {
                    $q = $q->orwhereNotNull('a_sync_datetime');
                }
                if ($request->filled('b_synced')) {
                    $q = $q->orwhereNotNull('b_sync_datetime');
                }
                if ($request->filled('c_synced')) {
                    $q = $q->orwhereNotNull('c_sync_datetime');
                }
                return $q;
            });
        }
        if ($request->filled('large_category')) {
            $query = $query->where('large_category', $request->input('large_category'));
        }
        if (!$is_factory && $request->filled('middle_category')) {
            $query = $query->where('middle_category', $request->input('middle_category'));
        }
        if ($request->filled('supplier')) {
            $query = $query->where('supplier', $request->input('supplier'));
        }
        if ($request->filled('start_dt') && $request->filled('end_dt')) {
            $start = $request->input('start_dt') . ' 00:00:00';
            $end = $request->input('end_dt') . ' 23:59:59';
            $query = $query->whereBetween('created_at', [$start, $end]);
        }
        return $query;
    }

これをなんとかならないのか?

開放・閉鎖の原則を考えてみる

機能はしていますよ。一応。

ただ、上記のコードにはいくつかの問題があります:

  • クソ長い、読むだけでも大変
  • もし要求が変わり、検索項目を追加したいときに、またif文を増やしたり、条件の間の関係によって複雑度が増していきます
  • 極端の話、検索項目が100個あれば、この関数を永遠に書いていくのか
  • 重複利用ができない、実際にもう一つの検索画面があり、若干違うだけだが、もう一度これを繰り返さないといけない
  • 違う検索項目が同じ関数にあり、結合度が高いし、注意しないと干渉しやすい→間違いにつながる
  • 関数を変更する理由が多すぎる(検索項目のどれを変えようとしてもこちらの関数を変更しなければならない)

などなど。

それで先日SOLID原則について少し勉強していて、開放・閉鎖の原則の例で少しヒントをもらいました。要するに、拡張には開放的でありながら、変更には閉鎖的であるとのことです。

考え方として、一つのクエリー生成用のクラスQueryGeneratorを用意し、他の検索項目は全て一個一個のQueryClassとして独立させ、それで全てのQueryClassに共通のInterfaceを実装するとのの構造にしてみました。

QueryGeneratorのクラスでは、generateというメソッドがあり、ここでSearchableInterfaceを実装している全てのクラスをループし、条件が満たすクラスがあればQueryをチェインしていくと。

インターフェースには、2つのメソッドがあります:

  • 検索のquery stringにこの項目の検索内容が入っているかどうか、テーブルによってこの項目を検索できるかどうかを判断するメソッド
  • クエリーにこの項目の検索Queryを追加・チェインしていくメソッド

これで全ての検索クラスにこの二つのメソッドを実装すればロジックは成り立ちます。もちろん、クエリーだけではなく、RAW SQLを文字列の形で繋げていくことも可能ですが、すでにクエリーのコードがありましたのでそのまま利用します。

interface SearchableInterface
{
    public function meetsCondition(): bool;
    public function chainQuery(Object $query): Object;
}
 
class QueryItemName implements SearchableInterface
{
    public function __construct(String $table, array $qs)
    {
        $this->table = $table;
        $this->qs = $qs;
    }
 
    public function meetsCondition(): bool
    {
        return isset($this->qs['item_name']);
    }
 
    public function chainQuery(Object $query): Object
    {
        $name = $this->qs['item_name'];
        return $query->where($this->table . '.name', 'LIKE', '%' . $name . '%');
    }
}
 
class QueryMiddleCategory implements SearchableInterface
{
    public function __construct(String $table, array $qs)
    {
        $this->table = $table;
        $this->qs = $qs;
    }
 
    public function meetsCondition(): bool
    {
        return isset($this->qs['middle_category']) && $this->table !== 'items_factories';
    }
 
    public function chainQuery(Object $query): Object
    {
        $code = $this->qs['middle_category'];
        return $query->where('middle_category', $code);
    }
}
 
class QuerySyncState implements SearchableInterface
{
    public function __construct(String $table, array $qs)
    {
        $this->table = $table;
        $this->qs = $qs;
    }
 
    public function meetsCondition(): bool
    {
        return (isset($this->qs['a_synced'])
            || isset($this->qs['b_synced'])
            || isset($this->qs['c_synced']))
            && $this->table !== 'items_factories';
    }
 
    public function chainQuery(Object $query): Object
    {
        return $query->where(function ($q) {
            if (isset($this->qs['a_synced'])) {
                $q = $q->orwhereNotNull('a_sync_datetime');
            }
            if (isset($this->qs['b_synced'])) {
                $q = $q->orwhereNotNull('b_sync_datetime');
            }
            if (isset($this->qs['c_synced'])) {
                $q = $q->orwhereNotNull('c_sync_datetime');
            }
            return $q;
        });
    }
}
 
// 他の全ての検索項目合計8個...

全てを入れていませんが、同じパターンなのでここは省略とします。

最後はQueryGeneratorクラスです。

class QueryGenerator
{
    private $table;
    private $qs;
 
    public function __construct(String $table, array $qs)
    {
        $this->table = $table;
        $this->qs = $qs;
    }
 
    // インターフェイスを実装している全てのクラスを取得
    private function getQueryClasses()
    {
        $interface = 'App\Http\Controllers\Interfaces\SearchableInterface';
        return array_filter(
            get_declared_classes(),
            function ($class_name) use ($interface) {
                return in_array($interface, class_implements($class_name));
            }
        );
    }
 
    // 必須条件がないため、全ての結果を取得するQueryから始める
    private function initializeQuery()
    {
        $columns = $this->getSearchColumns();
        $query = DB::table($this->table)
            ->whereNull('deleted_at')
            ->leftJoin('suppliers', $this->table . '.supplier_id', '=', 'suppliers.id')
            ->select($columns);
 
        return $query;
    }
 
    // テーブルによって検索項目を変える
    private function getSearchColumns()
    {
        switch ($this->table) {
            case 'items':
                $select = 'ITEM';
                break;
            case 'items_materials':
                $select = 'MATERIAL';
                break;
            case 'items_factories':
                $select = 'FACTORY';
                break;
            default:
                return [];
        }
        return config("const.SEARCH_COLUMN.{$select}");
    }
 
    // 条件に満たした場合のみQueryをチェインしていく
    public function generate()
    {
        $q = $this->initializeQuery();
        $query_classes = $this->getQueryClasses();
        foreach ($query_classes as $class) {
            $query_class = new $class($this->table, $this->qs);
            if ($query_class->meetsCondition()) {
                $q = $query_class->chainQuery($q);
            }
        }
        return $q;
    }
 
    // $items = (new QueryGenerator($table, $qs))->getItems() で運用可能
    public function getItems()
    {
        return $this->generate()->get();
    }
}

思わぬ問題

上記のコードで上手く行けるはず!と思って試してみましたが、なぜか検索フィルターが全く聞かず、全てのレコードが結果に出てきます。

それでデバグしている中で、どうやらget_declared_classes関数の動きが変だとわかりました。

定義されているクラスが全てリターンされるはずだと思ったのですが、なぜかQueryクラスが含まれていません。その理由を追究する中で、どうやらnew QueryItemName(...)をどこかに書いておかないと、get_declared_classesの結果に反映されない模様です。

機能させるために、全ての検索項目を対象に8回もインスタンス初期化のコードを書いてしまいましたが、どうも釈にならない気分です。

色々と検索してみて、どうやらcomposerのautoloadと関わっていて、使われていないクラスはリソース節約のためにロードされていません。こちらに同じ現象について説明されています。

試しに、composer.jsonファイルのautoload設定を少し弄ってみましたが、結果は以下となります:

  • classmapにクエリークラス所在のフォルダー・パスを入れましたが、ロードされませんでした
  • filesにファイルを一つずつ追加すると、追加されたファイルがロードされます
  • psr-4は関係ないですが一応試しに入れましたが効果がありませんでした

結局filesに追加すれば使われていなくても強制的にロードできるようです。一旦これで解決できますが、これだと検索項目のファイルが増えるたびに追加しなければなりませんので、理想的な解決法とは言えません。

こちらの問題について何か手かがりのある方がいらっしゃれば是非教えていただきたく思っています。

若干長くなりました。今日はこれで。

Discussion