🎻

[Symfony/Form] DateTimeTypeなどの必須項目がInvalidArgumentExceptionになる時に読む記事

2020/06/11に公開
Expected argument of type "DateTimeInterface", "null" given at property path "xxx".

このエラーメッセージでググっている人を救済できることを願って筆を取ります😇

これは何

  • あるエンティティに DateTimeInterface 型のプロパティがあって
  • そのプロパティは必須項目なので @Assert\NotBlank() アノテーションをつけてあり
  • 対応するFormTypeでは DateTimeTypeのフィールドとして定義していて
  • そのフィールドはもちろん 'required' => true である

という状態において、このフォームで当該フィールドを空にして送信すると、 $form->handleRequest() で適切にエラーが出力される前に、以下のようにSymfony内部で例外が発生してしまうケースがあります。

この原因と解決方法について説明します✋

具体的なコード例

まず、この現象を再現できる具体的なコードの例を書きます。

エンティティ

class Foo
{
    /**
     * @Assert\NotBlank()
     */
    private $datetime;

    public function getDatetime(): ?\DateTimeInterface
    {
        return $this->datetime;
    }

    public function setDatetime(\DateTimeInterface $datetime): self
    {
        $this->date = $datetime;

        return $this;
    }
}

FormType

class FooType extends AbstractType
{
    public function buildForm(FormBuilderInterface $builder, array $options)
    {
        $builder
            ->add('datetime', DateTimeType::class, [
                'label' => '日時',
                'widget' => 'single_text',
            ])
        ;
    }

    public function configureOptions(OptionsResolver $resolver)
    {
        $resolver->setDefaults([
            'data_class' => Foo::class,
        ]);
    }
}

一見すると普通の問題ないコードに見えますよね。ところがこれだと、 datetime フィールドを空欄にしてフォームを送信した場合に先ほどのエラーになります。

このコードを見ただけで「あーハイハイ、確かにこれは例外吐くわ」と想像できた人はSymfonyマスターと呼ばせていただきます🍀

原因

先ほどの例外の発生箇所は下図のとおりだった(Symfony v4.4.7 での例)でした。

そこで PropertyAccessor::setValue() のコードを見てみると、以下のような内容になっています。

public function setValue(&$objectOrArray, $propertyPath, $value)
{
    if (\is_object($objectOrArray) && false === strpbrk((string) $propertyPath, '.[')) {
        $zval = [
            self::VALUE => $objectOrArray,
        ];

        try {
            $this->writeProperty($zval, $propertyPath, $value);

            return;
        } catch (\TypeError $e) {
            self::throwInvalidArgumentException($e->getMessage(), $e->getTrace(), 0, $propertyPath);
            // It wasn't thrown in this class so rethrow it
            throw $e;
        }
    }
    
    // ...

https://github.com/symfony/symfony/blob/4.4/src/Symfony/Component/PropertyAccess/PropertyAccessor.php#L106-L122

どうやら $this->writeProperty($zval, $propertyPath, $value) を実行しようとして例外になっているようです。

ステップ実行 してみると分かりますが、問題の例外発生時、上記のコードにおいて各変数の値は

変数
$objectOrArray Foo エンティティのインスタンス
$propertyPath (文字列表現では) 'datetime'
$value null

となっていて、 writeProperty() メソッドの内部まで追っていくと、

$object->{$access[self::ACCESS_NAME]}($value);

https://github.com/symfony/symfony/blob/4.4/src/Symfony/Component/PropertyAccess/PropertyAccessor.php#L563

この部分で $foo->setDatetime(null) が呼ばれています。

さて、ここで改めて Foo エンティティのコードを見てみましょう。

public function setDatetime(\DateTimeInterface $datetime): self
{
    $this->date = $datetime;

    return $this;
}

setDatetime() メソッドの引数型宣言がnullableではありませんね。

このため、

Expected argument of type "DateTimeInterface", "null" given at property path "datetime".

という例外が発生します。

解決方法その1

「setterの引数がnullableじゃないのにnullをセットしようとしてしまう」ことが直接の原因だったので、以下のようにsetterの引数をnullableにしてあげれば解決できます👌

- public function setDatetime(\DateTimeInterface $datetime): self
+ public function setDatetime(?\DateTimeInterface $datetime): self
{
    $this->date = $datetime;

    return $this;
}

ただ、 $datetime プロパティは必須項目なのでnullを明示的にセットすることができるコード仕様にしてしまうのはイマイチな気もします🤔

MakerBundlemake:entity コマンドで必須のプロパティを作ると、自動生成されるsetterは引数がnullableでない仕様になります。

解決方法その2

少し掘り下げて、問題の PropertyAccessor::setValue() の呼び出し元のコードを見てみると、以下のような内容になっています。

// If the data is identical to the value in $data, we are
// dealing with a reference
if (!\is_object($data) || !$config->getByReference() || $propertyValue !== $this->propertyAccessor->getValue($data, $propertyPath)) {
    $this->propertyAccessor->setValue($data, $propertyPath, $propertyValue);
}

https://github.com/symfony/symfony/blob/4.4/src/Symfony/Component/Form/Extension/Core/DataMapper/PropertyPathMapper.php#L85-L87

これによると、どうやら setValue() が呼び出される条件は

  • フォームデータがオブジェクトでない、または
  • フォームフィールドの by_reference オプションが false である、 または
  • フォームから直接取り出したプロパティの値と、フォームデータオブジェクトから取り出したプロパティの値が異なっている

となっているようです。

確かに、 DateTypeDateTimeTypeTimeType においては by_reference のデフォルト値が false となっています。

というわけで、(取り急ぎ)以下のように by_referencetrue に変更すれば、setterのメソッドシグネチャを変更しなくてもエラーが起こらないようにできます👌

$builder
    ->add('datetime', DateTimeType::class, [
        'label' => '日時',
        'widget' => 'single_text',
+       'by_reference' => true,
    ])
;

そもそも by_reference とは

難しいと言われるSymfonyのFormコンポーネントの機能の中でも by_reference は特に分かりづらいオプションだと個人的に思っています😓

ただ、そうは言っても ドキュメント にはちゃんと詳しく説明が書かれていて、何をするためのオプションなのかは一応読めば分かるようになっています。(僕の理解が正しければ…)

言葉で説明するのが難しすぎるのでドキュメントに記載されている例をそのまま引用しますが、

$builder = $this->createFormBuilder($article);
$builder
    ->add('title', TextType::class)
    ->add(
        $builder->create('author', FormType::class)
            ->add('name', TextType::class)
            ->add('email', EmailType::class)
    )

こんな構造のフォームがあるとして、 author の下の name email というフィールドの値をおおもとのデータオブジェクトである $article にセットする方法には、以下の2通りが考えられますよね。

// パターン1
$article->setTitle('...');
$article->getAuthor()->setName('...');
$article->getAuthor()->setEmail('...');
// パターン2
$article->setTitle('...');
$author = clone $article->getAuthor();
$author->setName('...');
$author->setEmail('...');
$article->setAuthor($author);

この2つのパターンのどちらを使うかを制御するためのオプションが by_reference です。

by_referencetrue ならパターン1、 false ならパターン2が採用されます。

この例で言えば、「 $article から見た name email を、 setAuthor() を使うことなく、 getAuthor() からの参照経由で(by referece)セットする」という意味で by_reference というわけですね。

逆に言えば、 by_referencefalse にすることの意味は、「 $article がもともと持っている author の内部を書き換えることをせず、丸ごと新しく作った author に置き換える」ということになります。

さて、ここで本題に戻って、DateTypeDateTimeTypeTimeType においては by_reference のデフォルト値が false となっていたことを思い出してみましょう。

上記リンク先のドキュメントには

The DateTime classes are treated as immutable objects.

と書かれており、また DateTimeType のコードの該当箇所にも以下のようなコメントが見られます。

// Don't modify \DateTime classes by reference, we treat
// them like immutable value objects
'by_reference' => false,

https://github.com/symfony/symfony/blob/4.4/src/Symfony/Component/Form/Extension/Core/Type/DateTimeType.php#L263-L265

どうやら、 DateTime のインスタンスそれ自体をフォーム経由で変更させたくないという意図のようですね。(多分)

しかし、今回のケースでは別に DateTime のインスタンス自体に対して setDate() とか setTime() とか呼ぼうとしてるわけじゃないので、 by_referencetrue に変更するのは特に問題ないと思っています。

それにしても、上のほうで書いたとおり、個人的には make:entity コマンドが生成するコードに倣って必須項目のsetterは引数をnot nullableにしてるんですが、たまたま DateTime 系以外のほとんどのFormTypeがデフォルトで 'by_reference' => true だから顕在化していないだけで、 false にした途端 handleRequest() できなくなってしまうというのは何とも言えない気持ちになります。一体何が正解なのか😓

まとめ

  • Symfonyのフォームで、 DateTypeDateTimeType の必須項目が handleRequest() 中に InvalidArgumentException になるときは、データクラスのsetterの引数をnullableにするか、 by_referencetrue にすればよい
GitHubで編集を提案

Discussion