Symfony Advent Calendar 2018 23日目の記事です!

メリークリスマスイブイブ!下田です。

今日は僕の大好きな機能の一つである Doctrine Criteria について書きたいと思います。

Doctrine Criteriaとは

ざっくり言うと、検索条件のみを表現できるオブジェクトです。

細かい粒度で作成したCriteriaを使いまわすことで、複雑に入り組んだ絞り込みを表現しやすくしたり、機能の追加や変更に強くすることができます。

ここではシンプルなブログ投稿サービスを構築中であるという例のもと、いろいろな要件が追加になった際に、Criteriaを使わないパターン、またCriteriaを適用するとどう書けるのかを示したいと思います。

使いドコロの例

前提

「投稿」を表す Post エンティティが次のような形であるとします。

<?php

namespace App\Entity;

use Doctrine\ORM\Mapping as ORM;

/**
 * 投稿
 * @ORM\Entity(repositoryClass="App\Repository\PostRepository")
 */
class Post
{
    /**
     * @ORM\Id()
     * @ORM\GeneratedValue()
     * @ORM\Column(type="integer")
     */
    private $id;

    /**
     * 投稿タイトル
     * @ORM\Column(type="string", length=255)
     */
    private $title;

    /**
     * 投稿本文
     * @ORM\Column(type="text")
     */
    private $body;

}

タイトル・本文だけの状態です。シンプルですね。

記事一覧を表示するControllerのアクションはこんな感じ

<?php

namespace App\Controller;

use App\Repository\PostRepository;
use Symfony\Bundle\FrameworkBundle\Controller\AbstractController;
use Symfony\Component\HttpFoundation\Response;
use Symfony\Component\Routing\Annotation\Route;

/**
 * @Route("/post")
 */
class PostController extends AbstractController
{
    /**
     * @Route("/", name="post_index", methods="GET")
     */
    public function index(PostRepository $postRepository): Response
    {
        return $this->render('post/index.html.twig', ['posts' => $postRepository->findAll()]);
    }
}

現在は特に絞り込みを行わず、 $postRepository->findAll() で全ての投稿を取得しています。

要件その1:投稿の公開期間を絞りたい

ここで、要件追加マンである偉い人に登場してもらいます。(架空の人物です)

偉い人『公開開始日時に自動で公開されるようにして、公開終了日時が来たら見られなくなるようにしてほしいな』

との要件が舞い込んできました。さっそく対応しましょう。

<?php

namespace App\Entity;

use Doctrine\ORM\Mapping as ORM;

class Post
{
    /** ... */

    /**
     * @ORM\Column(type="datetime")
     */
    private $publishedStart;

    /**
     * @ORM\Column(type="datetime")
     */
    private $publishedEnd;

}

「投稿」に「公開開始日時」「公開終了日時」を追加した状態です。

ブログ一覧画面で公開期間中のもののみ一覧に表示させたいので、Repositoryに公開中のPostを取得するメソッドを実装して、

<?php

namespace App\Repository;

use Doctrine\Bundle\DoctrineBundle\Repository\ServiceEntityRepository;

class PostRepository extends ServiceEntityRepository
{
    /**
     * 公開中の記事
     */
    public function findPublished()
    {
        return $this->createQueryBuilder('p')
            ->andWhere('p.publishedStart <= :now')
            ->andWhere('p.publishedEnd > :now')
            ->setParameter('now', new \DateTime())
            ->getQuery()
            ->getResult()
            ;
    }
}

Controllerのアクションで呼んであげれば良いですね。ヨイショ!

class PostController extends AbstractController
{
    /**
     * @Route("/", name="post_index", methods="GET")
     */
    public function index(PostRepository $postRepository): Response
    {
-        return $this->render('post/index.html.twig', ['posts' => $postRepository->findAll()]);
+        return $this->render('post/index.html.twig', ['posts' => $postRepository->findPublished()]);
    }
}

いけましたね!余裕余裕!

要件その2:技術記事だけ絞りたい

偉い人『「技術記事」だけフラグ立てて、技術記事だけの一覧を表示できないかな』

だんだん嫌な予感がしてきましたが、とりあえず実装することに。

<?php

namespace App\Entity;

use Doctrine\ORM\Mapping as ORM;

class Post
{
    /** ... */

    /**
     * 技術記事かどうか 
     * @var boolean
     * @ORM\Column(type="boolean", options={"default" : false})
     */
    private $isTech;
}

「技術記事」のフラグを作りました。

<?php

namespace App\Repository;

use Doctrine\Bundle\DoctrineBundle\Repository\ServiceEntityRepository;

class PostRepository extends ServiceEntityRepository
{
    /** ... */

    /**
     * 技術記事
     */
    public function findIsTech(bool $flag = true)
    {
        return $this->createQueryBuilder('p')
            ->andWhere('p.isTech = :flag')
            ->setParameter('flag', $flag)
            ->getQuery()
            ->getResult()
        ;
    }
}

Repositoryにも技術記事を取得するメソッドを書いて…と。

おっと。公開期間のことを忘れていました。公開中 かつ 技術記事である メソッドも必要そうです。

<?php

class PostRepository extends ServiceEntityRepository
{
    /** ... */

    /**
     * 公開中かつ技術記事
     */
    public function findPublishedAndIsTech(bool $flag = true)
    {
        return $this->createQueryBuilder('p')
            ->andWhere('p.publishedStart <= :now')
            ->andWhere('p.publishedEnd > :now')
            ->setParameter('now', new \DateTime())
            ->andWhere('p.isTech = :flag')
            ->setParameter('flag', $flag)
            ->getQuery()
            ->getResult()
            ;
    }

}

…だんだん雲行きが怪しくなってきました。DRYじゃないですよね。

この実装方針の問題点として、要件追加された 検索条件 を独立して扱えていないことが挙げられます。

DoctrineのQueryBuilderでQueryオブジェクトを組み立てる方法は便利なのですが、このように細かい単位の検索条件を組み合わせて使いたい場合には向いていません。

ではどうすれば良いのでしょうか??…………そうです。お待ちかねのCriteriaの出番です。さっそく適用してみましょう!

改善: Criteriaを使ってみる

Criteriaの書き方はちょっとQueryBuilderと違うのでコツがいるのですが、基本的に比較演算子の代わりに専用のメソッドを使い、引数に値を渡して使うような形です。

まずは「公開中」「技術記事である」という条件を持ったCriteriaを返してくれるクラスを作成してみましょう。

<?php

namespace App\Criteria;

use Doctrine\Common\Collections\Criteria;

class PostCriteria
{
    public static function published()
    {
        return Criteria::create()
            ->andWhere(Criteria::expr()->lte('publishedStart', new \DateTime))
            ->andWhere(Criteria::expr()->gt('publishedEnd', new \DateTime))
            ;
    }

    public static function isTech(bool $flag = true)
    {
        return Criteria::create()
            ->andWhere(Criteria::expr()->eq('isTech', $flag))
            ;
    }
}

↑のような形になりました。

Repositoryでこれまで作ったメソッドに適用すると、こうなります。

<?php

namespace App\Repository;

use Doctrine\Bundle\DoctrineBundle\Repository\ServiceEntityRepository;

class PostRepository extends ServiceEntityRepository
{
    /** ... */

    /**
     * 公開中の記事
     */
    public function findPublished()
    {
        return $this->createQueryBuilder('p')
            ->addCriteria(PostCriteria::published())
            ->getQuery()
            ->getResult()
            ;
    }

    /**
     * 技術記事
     */
    public function findIsTech(bool $flag = true)
    {
        return $this->createQueryBuilder('p')
            ->addCriteria(PostCriteria::isTech($flag))
            ->getQuery()
            ->getResult()
            ;
    }

    /**
     * 公開中かつ技術記事
     */
    public function findPublishedAndIsTech(bool $flag = true)
    {
        return $this->createQueryBuilder('p')
            ->addCriteria(PostCriteria::published())
            ->addCriteria(PostCriteria::isTech($flag))
            ->getQuery()
            ->getResult()
            ;
    }
}

まだちょっとDRYでない感はありますが、格段にわかりやすくなりました。

公開中 という、意味としてまとまった絞り込み条件を「公開開始日時より後でかつ公開終了日時より前」と毎回記述しなくてよくなる、というのも地味にポイント高いです。

さらに要件その3: 執筆者ごとに絞りたい

偉い人『記事の執筆者のページで、その人の記事一覧を表示したいんだよね』

偉い人『あ、もちろん公開中のやつね。技術記事だけでいいや』

偉い人に再度登場してもらいました。今回の要件はリレーションでEntityを繋ぎ、かつこれまでの絞り込み機能も生きたまま、のパターンです。

<?php

namespace App\Entity;

use Doctrine\ORM\Mapping as ORM;

class Post
{
    /** ... */

    /**
     * 執筆者 
     * @var Writer
     * @ORM\ManyToOne(targetEntity="Writer", inversedBy="posts")
     */
    private $writer;
}
<?php

namespace App\Entity;

use Doctrine\ORM\Mapping as ORM;

/**
 * 執筆者
 * @ORM\Entity(repositoryClass="App\Repository\WriterRepository")
 */
class Writer
{
    /** ... */
 
    /**
     * @ORM\Column(type="string", length=255)
     */
    private $name;

    /**
     * 記事一覧
     * @ORM\OneToMany(targetEntity="Post", mappedBy="writer")
     */
    private $posts;
}

Writer(執筆者)Entityを作り、OneToManyでつなぎました。

執筆者のページを表示するためのControllerはこんな感じ

<?php

namespace App\Controller;

use App\Entity\Writer;
use Symfony\Bundle\FrameworkBundle\Controller\AbstractController;
use Symfony\Component\HttpFoundation\Response;
use Symfony\Component\Routing\Annotation\Route;

/**
 * @Route("/writer")
 */
class WriterController extends AbstractController
{
    /** ... */

    /**
     * @Route("/{id}", name="writer_show", methods="GET")
     */
    public function show(Writer $writer): Response
    {
        return $this->render('writer/show.html.twig', [
            'writer' => $writer,
            'posts' => $writer->getPosts()
        ]);
    }
}

posts 変数に $writer->getPosts()を渡して、執筆者の記事だけを絞り込んで表示しています。

ここへ、これまでの 公開中技術記事 かどうかを反映させるにはどうすれば良いでしょうか。

いきなり結論ですが、これまでに作ったCriteriaはなんと リレーションにも直接適用する ことができます!

    /**
     * @Route("/{id}", name="writer_show", methods="GET")
     */
    public function show(Writer $writer): Response
    {
        return $this->render('writer/show.html.twig', [
            'writer' => $writer,
            'posts' => $writer->getPosts()
+                ->matching(PostCriteria::published())
+                ->matching(PostCriteria::isTech())
        ]);
    }

OneToManyでつないだ先のコレクションは Doctrine/ORM/PersistentCollection なので、matching()メソッドにCriteriaを渡して絞り込むことができます。

複数渡したい場合は上記の様にメソッドチェーンで繋げばOKです。

上の例ではControllerで細かく指定していますが、仮に「ある執筆者の公開中の技術記事」というひとまとまりで扱うことが多いのであれば、執筆者Entityに専用のgetterとして実装してしまっても良いですね。

<?php

namespace App\Entity;

class Writer
{
    /** ... */

    /**
     * この執筆者の公開中の技術記事
     */
    public function getPublishedTechPosts()
    {
        return $this->posts
            ->matching(PostCriteria::published())
            ->matching(PostCriteria::isTech())
        ;
    }
}

こうしておけば、Controllerの仕事を減らして、view側で直接取得もできますね。

このような絞り込みの用途でRepositoryを使おうと思うと、サービスコンテナから取得したり予めDIしておいたりしないといけませんが、Criteriaは軽い部品なのでいつでもどこでも組み立てることが可能なため、Entity内部でも取り回しやすいです。

補足: パフォーマンス面はどうなの?

ちょっと気になるのが、 matching() メソッドをたくさんつないだ場合についてです。matching() メソッドのたびに、SQLが発行されて遅くなったりしないんでしょうか?

上記の show アクションを実行した時に発行されているSQLをprofilerで確認してみましょう。

SELECT t0.id AS id_1, t0.title AS title_2, t0.body AS body_3, t0.published_start AS published_start_4, t0.published_end AS published_end_5, t0.is_tech AS is_tech_6, t0.writer_id AS writer_id_7
FROM post t0 
WHERE ((t0.published_start <= ? AND t0.published_end > ?) AND t0.writer_id = ?)

どうやら、複数の matching() メソッドをつなげた場合でも、SQLをうまく1本につなげて実行してくれるようです。Doctrineは賢いですね!

まとめ

ここまで、 Doctrine Criteria の使い方について実例をあげて書いてみました。

絞り込み条件だけを独立させ、小さなフィルタのような部品として作り、それらの組み合わせや使い回しで複雑な条件を表現することができるため、かなり使い勝手が良いと思うのですが、日本語記事がほとんど見つけられなかったので今回ご紹介させていただきました。

LaravelのEloquentにおけるクエリスコープのような考え方をDoctrineの世界でも使えるわけですね。

みなさんも機会があればぜひ、 Doctrine Criteria を使ってみて下さい!