[Symfony/Form] DateTimeTypeなどの必須項目がInvalidArgumentExceptionになる時に読む記事
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;
}
}
// ...
どうやら $this->writeProperty($zval, $propertyPath, $value)
を実行しようとして例外になっているようです。
ステップ実行 してみると分かりますが、問題の例外発生時、上記のコードにおいて各変数の値は
変数 | 値 |
---|---|
$objectOrArray |
Foo エンティティのインスタンス |
$propertyPath |
(文字列表現では) 'datetime'
|
$value |
null |
となっていて、 writeProperty()
メソッドの内部まで追っていくと、
$object->{$access[self::ACCESS_NAME]}($value);
この部分で $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を明示的にセットすることができるコード仕様にしてしまうのはイマイチな気もします🤔
MakerBundle の
make: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);
}
これによると、どうやら setValue()
が呼び出される条件は
- フォームデータがオブジェクトでない、または
-
フォームフィールドの
by_reference
オプションがfalse
である、 または - フォームから直接取り出したプロパティの値と、フォームデータオブジェクトから取り出したプロパティの値が異なっている
となっているようです。
確かに、 DateType、DateTimeType、TimeType においては by_reference
のデフォルト値が false
となっています。
というわけで、(取り急ぎ)以下のように by_reference
を true
に変更すれば、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_reference
が true
ならパターン1、 false
ならパターン2が採用されます。
この例で言えば、「 $article
から見た name
email
を、 setAuthor()
を使うことなく、 getAuthor()
からの参照経由で(by referece)セットする」という意味で by_reference
というわけですね。
逆に言えば、 by_reference
を false
にすることの意味は、「 $article
がもともと持っている author
の内部を書き換えることをせず、丸ごと新しく作った author
に置き換える」ということになります。
さて、ここで本題に戻って、DateType、DateTimeType、TimeType においては 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,
どうやら、 DateTime
のインスタンスそれ自体をフォーム経由で変更させたくないという意図のようですね。(多分)
しかし、今回のケースでは別に DateTime
のインスタンス自体に対して setDate()
とか setTime()
とか呼ぼうとしてるわけじゃないので、 by_reference
を true
に変更するのは特に問題ないと思っています。
それにしても、上のほうで書いたとおり、個人的には make:entity
コマンドが生成するコードに倣って必須項目のsetterは引数をnot nullableにしてるんですが、たまたま DateTime
系以外のほとんどのFormTypeがデフォルトで 'by_reference' => true
だから顕在化していないだけで、 false
にした途端 handleRequest()
できなくなってしまうというのは何とも言えない気持ちになります。一体何が正解なのか😓
まとめ
- Symfonyのフォームで、
DateType
やDateTimeType
の必須項目がhandleRequest()
中にInvalidArgumentException
になるときは、データクラスのsetterの引数をnullableにするか、by_reference
をtrue
にすればよい
Discussion