QueryBuilderのExprクラスでandXとorXを使ってみる

この記事は、Zennに投稿した以下の記事の再投稿になります。

備忘録です。

QueryBuilderのexprというメソッドでExprクラスのインスタンスを作ることができます。

$expr = $this->getEntityManager()
            ->getRepository(Product::class)
            ->createQueryBuilder('p')
            ->expr();

こんな感じ。
これで作ったExprクラスのインスタンスには、SQLの=likeに対応したメソッドがたくさん用意されています。

今回は、その中のandXメソッドとorXを使った検索機能を作っていきます。

andX、orXとは?

andXメソッドは、SQLの○○ where ○○ and ○○;andを表現するものです。引数に条件式を与えるか、インスタンスからaddメソッドを使って条件式を追加することで利用します。
orXは、同様にSQLのorを表しています。こちらも、引数に条件式を与えるか、インスタンスからaddメソッドを使って条件式を追加することで利用することができます。

$andX = $this->getEntityManager()
            ->getRepository(Product::class)
            ->createQueryBuilder('p')
            ->expr()
            ->andX();
$andX->add($qb->expr()->eq('p.id', ':id'));

または、

$andX = $this->getEntityManager()
            ->getRepository(Product::class)
            ->createQueryBuilder('p')
            ->expr()
            ->andX($qb->expr()->eq('p.id', ':id'));
$orX = $this->getEntityManager()
            ->getRepository(Product::class)
            ->createQueryBuilder('p')
            ->expr()
            ->orX();
$orX->add($qb->expr()->eq('p.id', ':id'));

または、

$orX = $this->getEntityManager()
            ->getRepository(Product::class)
            ->createQueryBuilder('p')
            ->expr()
            ->orX($qb->expr()->eq('p.id', ':id'));

これらの存在を知るまではSQLやDQLを直書きしていました。
正直めっちゃ見た目は悪かったし、タイポしても気付けないことが多くてモヤだったんですが、andXorXを知ってから実際に使ってみて感激しました。

まず見た目がいい。SQL/DQL直書きと比べて遥かに見やすいです。

そして、エラーが分かりやすいし、タイポもしにくくなります。SQLだと基本的にSyntax errorとしか出ないですが、Exprクラスのメソッドを使うと何処がエラーの原因になっているのかすぐにわかるエラーメッセージを出してくれるので助かりますね。

動作環境・前提

バージョン
Mac 12.x
PHP 7.4.33
MySQL 5.7
Symfony 5.4.21

作ってみる

では、早速作っていきます。
今回作るのは、商品の新規作成と検索を行う簡単な管理画面っぽいものです。

Controllerとテーブルの作成

Symfonyのコマンドを使って、ControllerとEntityの作成、マイグレーションの実行を行います。

  • Controllerの作成
$ php bin/console make:controller

実行するとControllerの名前を求められるので、適当に入力してください。
以降、ここでProductControllerというControllerを作成したものとして話を進めていきます。

  • Entityの作成
$ php bin/console make:entity

実行すると、Entityの名前とどんなプロパティが必要か聞かれます。こちらも適当に入力してください。
以降、ここでProductというEntityを作成したものとして話を進めていきます。また、このエンティティは次のプロパティを持っているとします。

mysql> show columns from product;
+-------+--------------+------+-----+---------+----------------+
| Field | Type         | Null | Key | Default | Extra          |
+-------+--------------+------+-----+---------+----------------+
| id    | int(11)      | NO   | PRI | NULL    | auto_increment |
| name  | varchar(255) | NO   |     | NULL    |                |
| price | int(11)      | NO   |     | NULL    |                |
+-------+--------------+------+-----+---------+----------------+
  • マイグレーションの実行
$ php bin/console make:migration
$ php bin/console doctrine:migrate

これで、DBにProductという名前のテーブルが作成されました。
ターミナルでSQLを叩くと、テーブルが作成されていることが確認できます。

mysql> show tables;
+---------------------+
| Tables_in_tech_blog |
+---------------------+
| messenger_messages  |
| product             |
+---------------------+

商品一覧

まずは作成済みの商品情報を表示する一覧画面を作っていきます。
この画面に、商品検索用のフォームも作ることにします。

Controllerはこんな感じ。

/**
 * @Route("/product", name="product_index", methods={"get", "post"})
 */
public function index(Request $request): Response
{
    $form = $this->createFormBuilder()
        ->add('multi', TextType::class, [
            'label' => "Product Id or Name",
            'required' => false,
        ])
        ->add('price', MoneyType::class, [
            'currency' => 'JPY',
            'label' => "Product Price",
            'required' => false,
        ])
        ->add('submit', SubmitType::class)
        ->getForm()
    ;

    // 以下に検索処理を書く
    if ("POST" === $request->getMethod()) {
    }

    return $this->render('product/index.html.twig', [
        'form' => $form->createView(),
    ]);
}

createFormBuilderメソッドでフォームを作成しています。
multiとしているフィールドでは、商品IDと商品名のどちらを入力しても検索できるようにします。priceでは、商品の料金で検索します。

まだ検索を行うRepositoryのメソッドを作成していないので、if文の中身は空っぽです。

twigはこんな感じ。

{% block body %}
<style>
    .example-wrapper { margin: 1em auto; max-width: 800px; width: 95%; font: 18px/1.5 sans-serif; }
    .example-wrapper code { background: #F5F5F5; padding: 2px 6px; }
</style>
<div class="example-wrapper">
<h1>Product Index</h1>
    {# 検索フォーム #}
    <form action="{{url('product_index')}}" method="post">
    {{ form_start(form) }}
    {{ form_widget(form) }}
    {{ form_end(form) }}
    </form>
    <a href="{{url('product_index')}}"><p>Reset</p></a>

    {# この下に作成済みの商品情報を表示する #}
</div>
{% endblock %}

Controllerから渡されたフォームを表示しています。
aタグで作っているのは、このページ自身へのリンクです。ページにアクセスし直すことで検索状況をリセットすることが目的なので、あってもなくてもいいです。

ここまでできたら、ブラウザで確認してみます。
以下のようになっていればOKです。

image03.png

商品作成

次に、商品を作成するページを作成します。
商品登録を行うRepositoryのメソッドは、先述した方法でエンティティを作成していれば自動で作られているはずです。ProductRepository.phpaddというメソッドです。

public function add(Product $entity): void
{
    $this->getEntityManager()->persist($entity);
    $this->getEntityManager()->flush();
}

このメソッドを利用して商品登録を行う画面を作っていきます。

Controllerはこんな感じ。

/**
 * @Route("/product/new", name="product_new", methods={"get", "post"})
 */
public function new(Request $request): Response
{
    $form = $this->createFormBuilder(new Product())
        ->add('name', TextType::class, [
            'required' => true,
            'label' => "Product Name",
        ])
        ->add('price', MoneyType::class, [
            'currency' => 'JPY',
            'label' => "Product Price",
            'required' => true,
        ])
        ->add('submit', SubmitType::class)
        ->getForm()
    ;

    if ("POST" === $request->getMethod()) {
        $params = $request->get('form');

        $newProduct = new Product();
        $newProduct->setName((string)$params['name']);
        $newProduct->setPrice((int)$params['price']);

        $productRepository = new ProductRepository($this->managerRegistry);
        try {
            $productRepository->add($newProduct);
        } catch (Throwable $e) {
            return $this->render('product/new.html.twig', [
                'form' => $form->createView(),
                'error_message' => "ERROR: {$e->getMessage()}",
            ]);
        }

        return $this->redirectToRoute("product_index");
    }

    return $this->render('product/new.html.twig', [
        'form' => $form->createView(),
    ]);
}

長いのでパーツごとに見ていきます。

$form = $this->createFormBuilder(new Product())
    ->add('name', TextType::class, [
        'required' => true,
        'label' => "Product Name",
    ])
    ->add('price', MoneyType::class, [
        'currency' => 'JPY',
        'label' => "Product Price",
        'required' => true,
    ])
    ->add('submit', SubmitType::class)
    ->getForm()
;

ここは商品一覧の時と同様に、createFormBuilderでフォームを作成している部分です。
このページでは商品作成を行うので、登録したい商品情報を入力するフォームですね。

プロパティはidnamepriceの3つですが、idは自動で採番されるのでわざわざ指定する必要はありません。
なので、作成するフォールドはnamepriceの2つで十分です。

if ("POST" === $request->getMethod()) {
    $params = $request->get('form');

    $newProduct = new Product();
    $newProduct->setName((string)$params['name']);
    $newProduct->setPrice((int)$params['price']);

    $productRepository = new ProductRepository($this->managerRegistry);
    try {
        $productRepository->add($newProduct);
    } catch (Throwable $e) {
        return $this->render('product/new.html.twig', [
            'form' => $form->createView(),
            'error_message' => "ERROR: {$e->getMessage()}",
        ]);
    }

    return $this->redirectToRoute("product_index");
}

ここは商品登録を行う部分です。先述した通り、ProductRepository.phpaddメソッドを使っています。

try-catchを使って、商品登録に失敗した場合はエラーメッセージを返すようにしています。
商品登録に成功したら、商品一覧にリダイレクトします。

twigはこんな感じ。

{% block body %}
<style>
    .example-wrapper { margin: 1em auto; max-width: 800px; width: 95%; font: 18px/1.5 sans-serif; }
    .example-wrapper code { background: #F5F5F5; padding: 2px 6px; }
</style>

<div class="example-wrapper">
    <h1>Create Product</h1>
    {# 商品作成フォーム #}
    <form action="{{url('product_new')}}" method="post">
    {{ form_start(form) }}
    {{ form_widget(form) }}
    {{ form_end(form) }}
    </form>

    <a href="{{url('product_index')}}"><p>Back to index</p></a>
</div>
{% endblock %}

商品一覧のtwigとほぼ変わりませんね。商品作成用のフォームと、商品一覧へのリンクがあるだけです。

ここまでできたら、商品の新規作成ができるようになっているはずです。

image01.gif

ターミナルでSQLを叩くと、商品がDBに登録されているのが確認できます。

mysql> select * from product;
+----+-----------+-------+
| id | name      | price |
+----+-----------+-------+
|  1 | Product A |  1000 |
|  2 | Product B |  2000 |
|  3 | Product C |  3000 |
+----+-----------+-------+

商品検索

さて、いよいよ本題の検索機能に取り掛かります。

まずはRepositoryに検索を行うメソッドを実装していきましょう。

/**
 * @param int|string $multi
 * @param int|string $price
 * @return Product
 */
public function findMany($multi = null, $price = null)
{
    $rep = $this->getEntityManager()->getRepository(Product::class);
    $qb = $rep->createQueryBuilder('p');
    $andX = $qb->expr()->andX();

    if (!is_null($multi)) {
        $andX->add(
            $qb->expr()->orX(
                $qb->expr()->eq('p.id', ':id'),
                $qb->expr()->like('p.name', ':name')
            )
        );
        $qb->setParameter(':id', (int)$multi)
            ->setParameter(':name', '%'.(string)$multi.'%')
        ;
    }

    if (!is_null($price)) {
        $andX->add(
            $qb->expr()->eq('p.price', ':price')
        );
        $qb->setParameter(':price', (int)$price);
    }

    if ((string)$andX !== "") {
        $qb->andWhere($andX);
    }

    $qb->addOrderBy('p.id', 'DESC');

    return $qb->getQuery()->getResult();
}

はい、ここでようやくandXorXの登場です。

1つずつ見ていきましょう。まずはここです。

$andX = $qb->expr()->andX();

AndXクラスのインスタンスを作成しています。

次はここです。ここが今回の肝になる部分です。

if (!is_null($multi)) {
    $andX->add(
        $qb->expr()->orX(
            $qb->expr()->eq('p.id', ':id'),
            $qb->expr()->like('p.name', ':name')
        )
    );
    $qb->setParameter(':id', (int)$multi)
        ->setParameter(':name', '%'.(string)$multi.'%')
    ;
}

if (!is_null($price)) {
    $andX->add(
        $qb->expr()->eq('p.price', ':price')
    );
    $qb->setParameter(':price', (int)$price);
}

この辺りで、実際に条件を指定しています。

DQLに直すとこんな感じ。

(前略) (id = :id or name like :name) and price = :price (後略)

:id:name:priceの部分に、フォームに入力した商品IDや名前、料金の値が入るわけですね。

上記のコードでは、findManyメソッドで引数に取っている $multi$priceがNULLでない場合 に条件が追加されます。
(太字にした部分を覚えておいてください!)

最後はこの部分。

if ((string)$andX !== "") {
    $qb->andWhere($andX);
}

andWhereメソッドが出てきました。これでwhereから始まる条件式の完成ですね。
ただ、if文の中にあります。このif文の条件は、

(string)$andX !== ""

となっています。$andXが文字列でない場合に限り、andWhereメソッドを実行するということですね。

さて、ひとつ前の解説で、「$multi$priceがNULLでない場合 に条件が追加される」と書きました。
つまり、$multi$priceがどちらもNULLなら、andWhereメソッドを実行すると以下のようなクエリになってしまいます。

select * from product where;

これではSyntax errorになってしまいますね。条件が指定されていない場合は、whereを消すようにしたいです。

この、「条件が指定されていない」ことを判定しているのが、上記のif文です。
addメソッドで追加した条件式は、AndXのインスタンス($andX)で文字列となって保持されます。

つまり、条件が指定されていない場合、文字列は空ということになりますね。
逆に言えば、空でないということは何か条件が追加されているということですから、whereがあってもエラーになりません。

では、このRepositoryのメソッドをControllerで使って、フォームに入力した値で検索できるようにしていきましょう。
商品一覧の画面を作ったときに何も書かなかったif文の中にコードを追加していきます。

/**
 * @Route("/product", name="product_index", methods={"get", "post"})
 */
public function index(Request $request): Response
{
    $form = $this->createFormBuilder()
        ->add('multi', TextType::class, [
            'label' => "Product Id or Name",
            'required' => false,
        ])
        ->add('price', MoneyType::class, [
            'currency' => 'JPY',
            'label' => "Product Price",
            'required' => false,
        ])
        ->add('submit', SubmitType::class)
        ->getForm()
    ;

    // 以下のコードを追加
    $searchMulti = null;
    $searchPrice = null;
    if ("POST" === $request->getMethod()) {
        $params = $request->get('form');
        $searchMulti = empty($params['multi']) ? null : $params['multi'];
        $searchPrice = empty($params['price']) ? null : $params['price'];
    }

    $products = $productRepository->findMany($searchMulti, $searchPrice);

    return $this->render('and_x/index.html.twig', [
        'form' => $form->createView(),
        // 以下のコードを追加
        'products' => $products,
    ]);
}

追加したコードの中で、検索機能に関わる部分を見ていきます。

$searchMulti = null;
$searchPrice = null;
if ("POST" === $request->getMethod()) {
    $params = $request->get('form');
    $searchMulti = empty($params['multi']) ? null : $params['multi'];
    $searchPrice = empty($params['price']) ? null : $params['price'];
}

$products = $productRepository->findMany($searchMulti, $searchPrice);

ここですね。

と言っても、そんなに難しいことはしてません。
フォームに入力した値がPOSTで送信されてきたら、その値を先ほど作ったProductRepositoryのfindManyメソッドに渡して検索処理を実行してもらっているだけです。
フォームが空だったらNULLを、そうでなければフォームごとの値を取り出して、メソッドに渡しています。

最後に、検索した商品情報をtwigで受け取って表示するようにしてきましょう。

Controllerと同じく商品一覧画面の時に書きかけていた部分にコードを追記していきます。
ついでに、商品作成画面への動線も作っておきます。

{% block body %}
<style>
    .example-wrapper { margin: 1em auto; max-width: 800px; width: 95%; font: 18px/1.5 sans-serif; }
    .example-wrapper code { background: #F5F5F5; padding: 2px 6px; }
</style>
<div class="example-wrapper">
<h1>Product Index</h1>
    {# 検索フォーム #}
    <form action="{{url('product_index')}}" method="post">
    {{ form_start(form) }}
    {{ form_widget(form) }}
    {{ form_end(form) }}
    </form>
    <a href="{{url('product_index')}}"><p>Reset</p></a>

    {# 商品作成画面への動線 #}
    <a href="{{url('product_new')}}"><p>Create new product</p></a>

    {# 商品が見つからなければ"Not found"のメッセージを、 #}
    {# 見つかったら商品情報を表にして表示する #}
    {% if products == null %}
    <p>Not found..</p>
    {% else %}
    <p>Found products.</p>
    <table>
        <thead>
            <tr>
                <th>ID</th>
                <th>NAME</th>
                <th>PRICE</th>
            </tr>
        </thead>
        <tbody>
            {% for product in products %}
            <tr>
                <td>{{product.getId}}</td>
                <td>{{product.getName}}</td>
                <td>{{product.getPrice}}</td>
            </tr>
            {% endfor %}
        </tbody>
    </table>
    {% endif %}
</div>
{% endblock %}

こうですね。

商品は複数取得するので、for文で繰り返して1つずつ表示します。
getIdgetNamegetPriceといったメソッドは、Entityを作成する際に自動で作成されているものをそのまま使います。
CSSは、必要に応じてお好みで調整してください。

ここまで出来たら、こんな感じの画面が確認できるようになっているはずです。

  • 商品を作成済みの場合
    image06.png

  • 商品が未作成の場合
    image05.png

検索機能も実装しましたので、試してみましょう。
以下の例では商品IDで検索を行なっていますが、もちろん商品名や料金での検索も可能です。
image02.gif

これで、商品の新規作成と検索の実装が終わりました。管理画面っぽいものの完成です!

最後に

長々とお付き合いいただきありがとうございました。

本当はRepositoryのメソッドだけ見せる予定だったんですが、せっかくだし検証も兼ねてちゃんと形になったものを作って手順もしっかりまとめちゃおう〜と思った結果こうなりました。

案件で実際にSQL直書きでどうにか複雑な条件式を書いていたことがあったんですが、andXorX使ったらコードがめちゃくちゃスッキリしたというか、「SQL直書きってやっぱりダサかったんだな!(個人の感想です)」といった感じで非常に感銘を受けました(?)。
Exprクラスには他にも便利なメソッドがたくさんあるので、実際に使って覚えていきたいですね。

Romy(ろみぃ)

歌とゲームと本が好きな人間。ドラゴンになりたい。
不定期で記事を更新していきます。
今後、ブログ以外にもコンテンツ追加していく予定。

© Romy 2024