DoctrineでSQLの日付関数を使う方法
Symfony Advent Calendar 2015 第一日目の記事です。 http://qiita.com/advent-calendar/2015/symfony
Symfony+Doctrine2でシステムを開発していて、「日付何年のデータだけほしい」、「何年何月のデータだけほしい」という場合。 どう対応していますか?
DateTimeで頑張る
まず、もっとも基本的なやり方として、DBから全件取得したうえで条件を満たすもののみの配列を作るという方法があります。 たとえば、今月ログインしたユーザーを取得したいなら、下記のようになります。
$today = new \DateTime('today');
$activeUsers = array_filter($em->getRepository('AcmeDemoBundle:User')->findAll(), function(\Acme\DemoBundle\Entity\User $user) use ($today){
return $user->getLastLoginAt()->getYear() === $today->getYear() && $user->getLastLoginAt()->getMonth() === $today->getMonth();
}); // [$user1, $user2, ...]
データ件数が少なければこれでも悪くないのですが、件数が多くなれば巨大なメモリが必要になってしまいます。 そこで、DBから取得する件数を減らすことを考えます。User::$lastLoginAtの保存されるカラムはDATETIME型なので、大小比較ができるはずです。
$today = new \DateTime('today');
$firstDayOfCurrentMonth = new \DateTime($today->format('Y-m-01 00:00:00'));
$lastDayOfCurrentMonth = new \DateTime('...'); // 略。結構面倒な計算が必要です…。
$activeUsers = $em->getRepository('AcmeDemoBundle:User')->createQueryBuilder('u')
->where('u.lastLoginAt >= :first_date AND u.lastLoginAt <= :last_date')
->setParameter('first_date', $firstDayOfCurrentMonth->format('Y-m-d 00:00:00'))
->setParameter('last_date', $lastDayOfCurrentMonth->format('Y-m-d 00:00:00'))
->getQuery()
->getResult() // [$user1, $user2, ...]
;
…できないわけではありませんが、「今月の最終日」の計算はなかなか面倒です。
Doctrine2でMySQLの日付関数を使う
ところで、MySQLにはDATETIME型のデータから一部の情報を取り出してくれる関数があります。 https://dev.mysql.com/doc/refman/5.6/ja/date-and-time-functions.html
これを使えば、全件取得も面倒な計算も要らなそうです。早速書いて実行してみましょう。
$today = new \DateTime('today');
$activeUsers = $em->getRepository('AcmeDemoBundle:User')->createQueryBuilder('u')
->having('year(u.lastLoginAt) = :current_year AND month(u.lastLoginAt) = :current_month')
->setParameter('current_year', $today->getYear())
->setParameter('current_month', $today->getMonth())
->getQuery()
->getResult()
; // QueryException [SemanticalError]
…エラーになってしまいました。 Doctrine2のQueryBuilderで使われるのはSQLではなくDQLなので、バックエンドがMySQLであってもMySQLの関数を直接使うことはできないのです。
MySQLの関数をカスタムDQL関数として定義する
MySQLの関数をDQLで使うためには、DQLのカスタム関数として登録する必要があります。 http://doctrine-orm.readthedocs.org/projects/doctrine-orm/en/latest/cookbook/dql-user-defined-functions.html
↑のリンク先にある カスタム関数定義の例 では、Lexer等難しそうな言葉が並んでいますが、今回作りたい日付関数はDQL関数として受け取った引数をそのままMySQLの関数に渡すだけなので、もっと単純に書けます。
下記は、MySQLの YEAR
関数に対応するDQL関数の定義例です。
namespace Acme\DemoBundle\Doctrine;
use Doctrine\ORM\Query\AST\Functions\FunctionNode;
use Doctrine\ORM\Query\Lexer;
use Doctrine\ORM\Query\Parser;
use Doctrine\ORM\Query\SqlWalker;
class Year extends FunctionNode
{
/**
* @var \Doctrine\ORM\Query\AST\Node
*/
public $dateExpression;
public function parse(Parser $parser)
{
$parser->match(Lexer::T_IDENTIFIER); // 関数名
$parser->match(Lexer::T_OPEN_PARENTHESIS); // 開きカッコ
$this->dateExpression = $parser->ArithmeticPrimary(); // DQL関数に渡された引数
$parser->match(Lexer::T_CLOSE_PARENTHESIS); // 閉じカッコ
}
public function getSql(SqlWalker $sqlWalker)
{
return 'YEAR('.$this->dateExpression->dispatch($sqlWalker).')';
}
}
MONTH
も、クラス名とgetSql()メソッド内のSQL関数名部分を置き換えるだけで同様に書けます。
このクラスをEntityManagerに登録することで
$config = new \Doctrine\ORM\Configuration();
$config->addCustomDatetimeFunction('month', '\Acme\DemoBundle\Doctrine\Month');
$config->addCustomDatetimeFunction('year', '\Acme\DemoBundle\Doctrine\Year');
$em = EntityManager::create($dbParams, $config);
DQLのカスタム関数としてQueryBuilderで使うことができます。(Symfony standard editionで使う場合は config.yml で設定できます)
早速使ってみましょう。
$today = new \DateTime('today');
$activeUsers = $em->getRepository('AcmeDemoBundle:User')->createQueryBuilder('u')
->having('year(u.lastLoginAt) = :current_year AND month(u.lastLoginAt) = :current_month')
->setParameter('current_year', $today->getYear())
->setParameter('current_month', $today->getMonth())
->getQuery()
->getResult()
; // [$user1, $user2, ...]
年別・月別等の集計や並べ替えも簡単にできるようになりました。
$today = new \DateTime('today');
$activeUsersCountByMonth = $em->getRepository('AcmeDemoBundle:User')->createQueryBuilder('u')
->select('count(u.id) as user_count, month(u.lastLoginAt) as last_login_month')
->having('year(u.lastLoginAt) = :current_year')
->setParameter('current_year', $today->getYear())
->groupBy('last_login_month')
->orderBy('last_login_month', 'asc')
->getQuery()
->getArrayResult()
; // [['last_login_month' => 1, 'user_count' => 100], ['last_login_month' => 2, 'user_count' => 90], ...]
Doctrine.DateFunctionExtensionを作りました
プロジェクトごとに同じ内容のクラスを書くのが面倒なので、再利用できるライブラリ化しました。 https://github.com/77web/Doctrine.DateFunctionExtension
composerでインストールできます。
$ composer require nanaweb/doctrine-date-function-extension
生のDoctrineを使っている場合はEntityManagerの初期化時に設定してください。
$config = new \Doctrine\ORM\Configuration();
$config->addCustomDatetimeFunction('month', '\Nanaweb\Doctrine\DateFunctionExtension\MySql\Month');
$config->addCustomDatetimeFunction('weekday', '\Nanaweb\Doctrine\DateFunctionExtension\MySql\Weekday');
$config->addCustomDatetimeFunction('year', '\Nanaweb\Doctrine\DateFunctionExtension\MySql\Year');
$config->addCustomDatetimeFunction('weekofyear', '\Nanaweb\Doctrine\DateFunctionExtension\MySql\WeekOfYear');
$em = EntityManager::create($dbParams, $config);
Symfony2で使う場合はconfig.ymlで設定するだけです。
orm:
# ...
entity_managers:
default:
# ...
dql:
datetime_functions:
month: \Nanaweb\Doctrine\DateFunctionExtension\MySql\Month
weekday: \Nanaweb\Doctrine\DateFunctionExtension\MySql\Weekday
year: \Nanaweb\Doctrine\DateFunctionExtension\MySql\Year
weekofyear: \Nanaweb\Doctrine\DateFunctionExtension\MySql\WeekOfYear
※ 現時点(2015年12月)で、SQLite3にも対応しています。SQLiteで使いたい場合はnamespaceの MySql
の部分を SQLite3
に書き換えてください。
明日は @polidog さんです!