Do You PHP はてブロ

Do You PHPはてなからはてブロに移動しました

Symfony2のFormTypeで日付フォーマットと妥当性の検証をする

このエントリはSymfony2.0.12でのお話です。2012/05/30にリリースされたSymfony2.0.15で不正な日付がエラーになるよう修正されました。このエントリ下部にある追記2を参照してください。

via. Twitter / @shimooka: timestamp型のカラムに対してYYYY/MM/ ...

入力フォームに日付を入力する欄を設けてその値をデータベースのtimestamp型なカラムに保存することはよくあると思います。
Symfony2に付属するFormTypeにも日付入力用のものがあります*1が、タイトルにある

  • 入力された日付のフォーマット(YYYY/MM/DD形式、など)
  • 入力された日付の妥当性チェック(2011/02/29は存在しない、など)

の2つをやってくれません。特に後者は2011/03/01のように"存在しない日付をすっ飛ばす"動作をし、必ずしも期待する動作でなない場合が多々あります。

なぜか?

日付入力用のFormTypeであるDateTypeがDataTransformerを使って、入力された日付文字列をDateTimeオブジェクトに変換してます。このTransformer*2内でIntlDateFormatterを使って一度UNIXタイムスタンプに変換し、それを使ってDateTimeオブジェクトを生成しているようですが、期待するフォーマットと異なる値を入力された場合にエラーとならず、結果的に正しくないタイムスタンプになってしまうことが原因のようです。
たとえば、yyyyMMdd形式での入力を期待しているところに"2011/02/28"と入力すると、最終的に生成されるDateTimeオブジェクトは1901/12/14という全く異なる日付を持ってしまいます。

でも、Constraintでチェックできるんじゃないの?

Constraintに渡ってくる値は変換後の値になります。つまり、変換後のDateTimeオブジェクトになっており、入力された文字列を直接参照できません。また、渡ってくるDateTimeオブジェクトを検証しても意味がありませんよね。。。

@Assert\Regex辺りが使えそうなんじゃ?

@Assert\Regexは変換後の型が文字列、もしくはオブジェクトが__toStringメソッドを実装している場合に使えます。が、残念ながらDateTimeクラスは__toStringメソッドを実装していません。

DateTypeの'pattern'オプションは?

正規表現を使ってある程度はチェックできますが、閏年の存在チェックがかなり厳しいと思われます【追記あり】。つか、そもそもHTML5のpattern属性に対応していないブラウザだと意味がありませんよね?

じゃあどうするか?


@iteman validatorを作るのは問題ないんですが、思い付いてる実装方法が「Entityに入力値を保持するプロパティを用意してvalidateし、prePresistで@Columnを付けたプロパティにsetする」というトリッキーなものなので、これでいいのかちと疑問です

ということで、要は「バリデーションを実行するプロパティとデータベースへの保存するプロパティを別々に用意し、実際にデータベースへ保存する際につなぎ合わせる」という考えでAcme/TimestampBundleとして作ってみました。コード一式はgithubにあります。

対象とするテーブルは以下のような定義になります(PostgreSQL8.xを使ってます)。

CREATE TABLE timestamps_with_constraint (
    id INTEGER NOT NULL,
    col1 TIMESTAMP NOT NULL,
    col2 TIMESTAMP NULL
);
ALTER TABLE timestamps_with_constraint
  ADD CONSTRAINT pk_timestamps_with_constraint
    PRIMARY KEY (id)
;
CREATE SEQUENCE timestamps_with_constraint_id_seq INCREMENT BY 1 MINVALUE 1 START 1;
入力フォーマットと妥当性をチェックする独自Constraint

独自Constraintの作成方法はSymfony2のCookbookにあるとおりです。
まずはアノテーション用のコードです。

<?php

namespace Acme\TimestampBundle\Component\Validator\Constraints;

use Symfony\Component\Validator\Constraint;

/**
 * Annotation for date format validation.
 *
 * @author  SHIMOOKA Hideyuki <shimooka@doyouphp.jp>
 * @Annotation
 */
class DateFormat extends Constraint
{
    public $message = 'This format is not valid';
    public $invalid = 'This date is not valid';
    public $pattern = 'Y-m-d';
    public $title;

    /**
     * {@inheritDoc}
     */
    public function getDefaultOption()
    {
        return array();
    }

    /**
     * {@inheritDoc}
     */
    public function getRequiredOptions()
    {
        return array();
    }
}

利用可能なオプションは以下のとおり。

  • message:フォーマットエラーの場合のメッセージ
  • invalid:妥当性チェックに失敗した場合のメッセージ
  • pattern:日付フォーマット。DateTimeクラスのformatメソッドで利用可能なもの
  • title:カラム名

もう一つ、バリデータのコードです。

<?php

namespace Acme\TimestampBundle\Component\Validator\Constraints;

use Symfony\Component\Validator\Constraint;
use Symfony\Component\Validator\ConstraintValidator;
use Symfony\Component\Validator\Exception\UnexpectedTypeException;

/**
 * date format validator.
 *
 * @author  SHIMOOKA Hideyuki <shimooka@doyouphp.jp>
 */
class DateFormatValidator extends ConstraintValidator
{
    public function isValid($value, Constraint $constraint)
    {
        if (null === $value || '' === $value) {
            return true;
        }

        if (!is_scalar($value) && !(is_object($value) && method_exists($value, '__toString'))) {
            throw new UnexpectedTypeException($value, 'string');
        }

        $value = (string) $value;

        $datetime = \DateTime::createFromFormat($constraint->pattern, $value);
        if ($datetime === false) {
            $this->setMessage($constraint->message, array(
                '{{ title }}' => $constraint->title,
                '{{ value }}' => $value,
            ));

            return false;
        } else if ($value !== $datetime->format($constraint->pattern)) {
            $this->setMessage($constraint->invalid, array(
                '{{ title }}' => $constraint->title,
                '{{ value }}' => $value,
            ));

            return false;
        }

        return true;
    }
}
Entityの考え方

バリデーション用とデータベースへの保存用のプロパティを用意します。流れは以下の様な感じです。

  1. Entity内の対象となるカラムからConstraints系のアノテーションを外す(直接バリデーションを実行しない様にする)
  2. Entityに"入力値を保持するためのプロパティ"を追加し、必要なAssertアノテーションを付ける。ただし、Columnアノテーションを付けない(この値に対してバリデーションを実行させる)
  3. EntityにHasLifecycleCallbacksアノテーションを追加してPrePersistアノテーションを持つメソッドを追加し、データベースのカラムに対応するプロパティにDateTimeオブジェクトを代入するコードを記述する

ここまでの内容を踏まえたEntityのコードは以下のようになります。

<?php

namespace Acme\TimestampBundle\Entity;

use Acme\TimestampBundle\Component\Validator\Constraints as AcmeAssert;
use Doctrine\ORM\Mapping as ORM;
use Symfony\Component\Validator\Constraints as Assert;

/**
 * @ORM\Table(name="timestamps_with_constraint")
 * @ORM\Entity()
 * @ORM\HasLifecycleCallbacks
 */
class TimestampsConstraint
{
    /**
     * @var integer $id
     *
     * @ORM\Column(name="id", type="integer")
     * @ORM\Id
     * @ORM\GeneratedValue(strategy="AUTO")
     */
    private $id;
    public function getId() { return $this->id; }
    public function setId($value) { $this->id = $value; }

    /**
     * @var string $col1 日付1
     *
     * @ORM\Column(name="col1", type="datetime", nullable=false)
     */
    protected $col1;
    public function getCol1() { return $this->col1; }
    public function setCol1($value) { $this->col1 = $value; }

    /**
     * @var string $col2 日付2
     *
     * @ORM\Column(name="col2", type="datetime", nullable=true)
     */
    protected $col2;
    public function getCol2() { return $this->col2; }
    public function setCol2($value) { $this->col2 = $value; }

    /**
     * バリデーションに使用する仮想的なカラム(日付1)
     *
     * @var string $col1Temporary 日付1
     *
     * @Assert\NotBlank(message="日付1は必須項目です")
     * @AcmeAssert\DateFormat(pattern="Y/m/d", title="日付1", message="{{ title }}はYYYY/MM/DD形式で入力してください", invalid="{{ title }}は有効な日付ではありません")
     */
    protected $col1Temporary;
    public function getCol1Temporary() { return $this->col1Temporary; }
    public function setCol1Temporary($value) { $this->col1Temporary = $value; }

    /**
     * バリデーションに使用する仮想的なカラム(日付2)
     *
     * @var string $col2Temporary 日付2
     *
     * @AcmeAssert\DateFormat(pattern="Ymd", title="日付2", message="{{ title }}はYYYYMMDD形式出入力してください", invalid="{{ title }}は有効な日付ではありません")
     */
    protected $col2Temporary;
    public function getCol2Temporary() { return $this->col2Temporary; }
    public function setCol2Temporary($value) { $this->col2Temporary = $value; }

    /**
     * @ORM\PrePersist
     */
    public function prePersist()
    {
        /**
         * 保存する前にカラムに値を設定する
         */
        $this->col1 = new \DateTime($this->getCol1Temporary());
        if ($this->getCol2Temporary() !== null || $this->getCol2Temporary() !== '') {
            $this->col2 = new \DateTime($this->getCol2Temporary());
        }
    }

}
Formの考え方

Entityにバリデーション用のプロパティを用意したので、フォームからの入力内容もそちらに向けることになります。入力フォームのコードは以下のとおり。'pattern'属性はあってもなくても問題ないです。

<?php

namespace Acme\TimestampBundle\Form;

use Symfony\Component\Form\AbstractType;
use Symfony\Component\Form\FormBuilder;

class TimestampsConstraintType extends AbstractType
{
    public function buildForm(FormBuilder $builder, array $options)
    {
        $builder
            ->add('col1Temporary', 'text',
                array(
                    'label' => '日付1',
                    'required' => true,
                    'pattern' => '^(19[0-9]{2}|2[0-9]{3})/((0[13578]|1[02])/(0[1-9]|[12][0-9]|3[01])|(0[469]|11)/(0[1-9]|[12][0-9]|30)|02/(0[1-9]|[12][0-9]))$',
                    'invalid_message' => '{{ title }}はYYYY/MM/DD形式で入力してください',
                ))
            ->add('col2Temporary', 'text',
                array(
                    'label' => '日付2',
                    'required' => false,
                    'pattern' => '^(19[0-9]{2}|2[0-9]{3})((0[13578]|1[02])(0[1-9]|[12][0-9]|3[01])|(0[469]|11)(0[1-9]|[12][0-9]|30)|02(0[1-9]|[12][0-9]))$',
                    'invalid_message' => '{{ title }}はYYYY/MM/DD形式で入力してください',
                ))
        ;
    }
    public function getName()
    {
        return str_replace('\\', '_', strtolower(__CLASS__));
    }

}

まとめ

ちょっとトリッキーですが、こんな感じで期待する動作をするようになりました。

@itemanさんもツイートされてましたが、Symfony2ってこの辺がまだ揃ってない or あまり情報が出てないので手探りの状態ですが、揃ってくるとコードを書かずにそれなりに実装できちゃいそうな感じがします:-)


@kunit 特に日本で使う場合のフォーム(特に変換)、フォームからバリデーションへの繋ぎの部分について不足しています。プロダクションレベルではビルトインのフォームタイプはまず使えない感じです。このあたり何かまとまった形で提供したいとは思っています。

追記(2012/04/23 11:57)

1900年代〜2000年代の閏年を考慮した正規表現の例がPHPマニュアルにありました(動作未確認)。まあ、力技でできなくはないなぁ、という感じですが。

*1:Symfony\Component\Form\Extension\Core\Type\DateTypeなど

*2:Symfony\Component\Form\Extension\Core\DataTransformer\DateTimeToLocalizedStringTransformer