【CakePHP 3.7.1】動的に要素数が変わるフォームのバリデーションを行いたい

タイトルの通り、要素数が変動する項目のバリデーションチェックを行いたいのですが、(どこかでやったことがありそうで)知らなかったため、備忘録の意味を込めてブログに書いてみようと思います。

条件

<form>
    <div>
        <div><input type="text" name="itemList[1]"></div>
    </div>
    <div><input type="submit" value="送信"></div>
</form>

こんな感じのHTMLで以下の条件を満たします。

  1. itemListは複数個(少なくとも1個)POSTされる。
    ※ 当たり前ですが、複数個送信される場合は、itemList[2]みたいな感じで増えます。
    ※ 送信される要素数はjs側で制御しているため、事前にわかりません。
  2. エラー時は、各項目ごと(itemList[1]、`itemList[2]`)にエラーメッセージを表示する。(各入力値も復元)

ネストしたバリデーター

CakePHP(>=3.0.5)では、バリデーションをネストすることができます。
長くなるので、詳細はリンク先を見ていただくとして、今回のケースでは、事前に要素数がわからないので利用することができませんでした。

既存機能だけでは難しそうだったため、とりあえず、エラーメッセージを返してくれるValidation::errors()関数の実装から追っていきたいと思います。

Validator

  • Validator::errors()
    リンク先の処理を追ってみると、202行目あたりの_processRules()関数で各フィールドごとのルール類をまとめて実施しているようです。次に_processRules()関数の実装を見てみようと思います。
  • Validator::_processRules()
    さらに処理を追っていると2398行目あたりで、ValidationSetオブジェクトをループさせ、各バリデーションを実施しているようです。2404行目では、ネストしたバリデーション時用の処理(子バリデーターで実施した結果のエラーメッセージを親バリデーターの結果として代入)を行っているようです。

これを見ている限り、Validator::add()関数で指定するエイリアスにValidator::NESTEDを指定した後、カスタムバリデーションでエラーメッセージを配列で返せるよう記述すると動くかも!!と思い、こんな感じで書いてみました。

  • ValidatonForm.php
    protected function _buildValidator(Validator $validator)
    {
        $validator
            ->requirePresence('itemList')
            ->add('itemList', Validator::NESTED, [
                'rule' => function ($value, $context) {
                    $errors = [];
                    foreach ($value as $id => $text) {
                        $isValid = Validation::notBlank($text);
                        if (!$isValid) {
                            $errors[$id]['_empty'] = '必須入力です。';
                        }
                    }
                    return (count($errors) === 0) ? true : $errors;
                },
            ]);
        return $validator;
    }
  • AppController
    public function multi()
    {
        $form = new ValidationForm();
        $data = $this->request->getData();
    
        if ($this->request->is(['patch', 'post', 'put'])) {
            $form->validate($data);
        }
    
        $this->set(compact('form', 'data'));
    }
  • multi.ctp
    <?= $this->Form->create($form); ?>
        <?php
        // 初期表示:1行分のみを表示
        // それ以外の場合:リクエストデータから入力値を復元(復元できない場合は初期表示と同じ)
        $itemList = (isset($data['itemList']) && is_array($data['itemList'])) ? $data['itemList'] : ['1' => ''];
        ?>
    
        <?php foreach ($itemList as $id => $value) : ?>
            <div>
                <?= $this->Form->control('itemList.' . $id); ?>
            </div>
        <?php endforeach; ?>
        <?= $this->Form->submit('送信'); ?>
    <?= $this->Form->end(); ?>

こんな感じで書いてみると、確かに1行単位のバリデーションを行うことができるようになりました。

ただ1点問題があります。HTMLが改ざんされ、itemListがPOSTされない場合です。

ちなみに、未入力の場合、HTMLが改ざんされた場合、フォームから取得したエラー情報はそれぞれ以下のようになります。

// debug($form->getErrors());
// 未入力の場合
[
	'itemList' => [
		(int) 1 => [
			'_empty' => '必須入力です。'
		],
		(int) 2 => [
			'_empty' => '必須入力です。'
		],
		(int) 3 => [
			'_empty' => '必須入力です。'
		]
	]
];

// HTMLが改ざんされた場合
[
	'itemList' => [
		'_required' => 'This field is required'
	]
];

なので、コレジャナイ感がありますが、ctpにこんな感じの記述を追加して対応しました。

<?= $this->Form->error('itemList._required'); ?>

以上。