LoginSignup
17
15

More than 3 years have passed since last update.

第2回 Laravelの required_if 系バリデーションのわかりにくい挙動を実験して確かめたまとめ + nullable_if カスタムルールの実装

Last updated at Posted at 2019-05-15

なにがしたい?

required_unless と真逆の nullable_if を実装する方法です。
特定条件のときにNULLを許可します。

まてまて、required_unless でええやんか、と思われた方は解説で弁解しているので読んであげてください…。

#解説以降は「Laravelの標準バリデーションのわかりにくい挙動を実験して確かめたまとめ」という記事の続編のようにテストしまくっています。合わせてご覧いただけると幸いです。

結論

こちらの記事を全面的に参考にさせていただきました…。
https://gist.github.com/byrnedo/5d07ac221d93d4af149086dda3a0dd97

こちらは標準のバリデーションを拡張しているので(おお!こんな方法があったのか)、そうじゃなくて、公式の拡張方法 Validator::extend で使えるロジックに直してみました。

ロジックはこんな感じのファイルを新規作成します。
ファイルを設置する場所は、ネームスペースとパスが一致していればどこでも大丈夫です。

app/Providers/Extensions/CustomeValidationLogics.php
namespace App\Providers\Extensions;

use Illuminate\Support\Arr;
use InvalidArgumentException;

class ValidationRules
{
    /**
     * 別のカラムがある値のときのみNULLを許可
     * https://gist.github.com/byrnedo/5d07ac221d93d4af149086dda3a0dd97
     * 
     * @param  string    $attribute
     * @param  mixed     $value
     * @param  array     $parameters
     * @param  Validator $validator
     * @return bool
     */
    public function validateNullableIf($attribute, $value, $parameters, $validator)
    {
        // この冒頭のIFは、requireParameterCountに代わる、引数の数のチェック。そんなに大事じゃない。
        $count = 2;
        if (count($parameters) < $count) {
            throw new InvalidArgumentException("Validation rule $rule requires at least $count parameters.");
        }

        $data = Arr::get($validator->attributes(), $parameters[0]);
        $values = array_slice($parameters, 1);

        if (is_bool($data)) {
            array_walk($values, function (&$value) {
                if ($value === 'true') {
                    $value = true;
                } elseif ($value === 'false') {
                    $value = false;
                }
            });
        }
        if (in_array($data, $values)) {
            // マッチしたら nullable ルールを追加
            $validator->mergeRules( $attribute, [$attribute => 'nullable'] );
            return true;
        }
        //miss
        return true;
    }
}

組み込みは、AppServiceProviderに書きます。

app/Providers/AppServiceProvider.php

class AppServiceProvider extends ServiceProvider
{
    public function boot()
    {
        Validator::extend(
            'nullable_if', 
            'App\Providers\Extensions\ValidationRules@validateNullableIf'
        );

        // ...

試してみます。

tinker
>>> $v = validator(
    ['period'=>0, 'object'=>null],
    ['object'=>'nullable_if:period,0|in:10,20,30']
); 
$v->passes(); 
=> true
$v->errors()->all();
=> []
tinker
>>> $v = validator(
    ['period'=>1, 'object'=>null],
    ['object'=>'nullable_if:period,0|in:10,20,30']
); 
$v->passes(); 
=> false
$v->errors()->all();
=> [
     "選択されたobjectは正しくありません。", // in ルールが弾いている。null が in に無いということ
   ]

補足ですが、メッセージはカスタマイズできません。ロジックのコードを見ると、nullable_if は常にTRUEを返していて失敗しないので、nullable_if に対するメッセージは使われないんですよね。どうしても「オプションにXXXが選択されているときは……」というメッセージにしたい場合は、in に対するメッセージをカスタムする必要があります。

解説

なにがしたい? ロングバージョン

最近はラジオボタンやセレクトボックスのように「固定された選択肢から何かを選ぶ」というオプションには、config とバリデーションルール in を使うようにしていて、割とうまく行っていたんですよね。例えば、こう。

config/const.php
"period" => [
    "none"       => 00, // 無条件
    "month"      => 10, // 月
    "prev_month" => 12, // 前月 1ヶ月前 ~ 1ヶ月前 と同じ
    "day"        => 20, // 日
    "prev_day"   => 22, // 前日 1日前 ~ 1日前 と同じ
    "today"      => 25, // 当日
],
"object" => [ 
    "products" => 10, // 物販
    "repair"   => 20, // 修理
    "cleaning" => 30, // クリーニング
],
xxxx.php
// とあるバリデーションルールの記述例
'period' => 'filled|in:' . implode(',', config('const.period')),
'object' => 'nullable|in:' . implode(',', config('const.object')),

ポイントは 0 と NULL を明確に区別 していて、NULLは「未選択」で何も判断されていない状態。一方、ハッキリとゼロであることに意味がある場合を除き、基本的に 0 は選択肢の中に持たせないように しています(今回の period は 0 に「条件がない」という意味をもたせている)。

# period に「無条件」ってどういう神経かーと思われた方……すみません、これはあくまで例なので。

image.png

ところが、現実のUIは ▲こうなっていて、無条件が選択されているときは他のオプションは要らない = NULLでいいよ!でもそれ以外のオプション時には必須でヨロシク、と。

なるほど、まさに required_unless ですね!

xxxx.php
'period' => 'filled|in:' . implode(',', config('const.period')),
'object' => 'reqired_unless,period:0|in:' . implode(',', config('const.object')),

試してみましょ。
period が 1 のときは object は NULL じゃダメなので…。

tinker
>>> $v = validator(
    ['period'=>1, 'object'=>null],
    ['object'=>'required_unless:period,0|in:10,20,30']
); 
$v->passes(); 
$v->errors()->all();
=> [
     "periodが0でない場合、objectを指定してください。",
   ]

よしよし期待通り。
で、period が 0 だと通るんですよね。

tinker
>>> $v = validator(
    ['period'=>0, 'object'=>null],
    ['object'=>'required_unless:period,0|in:10,20,30']
); 
$v->passes(); 
$v->errors()->all();
=> [
     "選択されたobjectは正しくありません。",
   ]

えええええー。なんでーーー!?

実証実験

ガンガンテストしていきますよーーー。

欲しいもの

とあるBLACKBOXルールを指定すると、ID2 が 0 以外のケースで、'id' => null を弾いてほしい。
0 はダメです。0とNULLは違う。0という選択肢は用意してないので。
つまり、こんなルールです。

結果 Rule 検査対象
🔴 ['id' => 'BLACKBOX|in:1,2,3'] ['id2' => 0, 'id' => 1]
['id' => 'BLACKBOX|in:1,2,3'] ['id2' => 0, 'id' => 0]
🔴 ['id' => 'BLACKBOX|in:1,2,3'] ['id2' => 0, 'id' => null]
🔴 ['id' => 'BLACKBOX|in:1,2,3'] ['id2' => 0]
🔴 ['id' => 'BLACKBOX|in:1,2,3'] ['id2' => 1, 'id' => 1]
['id' => 'BLACKBOX|in:1,2,3'] ['id2' => 1, 'id' => 0]
['id' => 'BLACKBOX|in:1,2,3'] ['id2' => 1, 'id' => null]
🔴 ['id' => 'BLACKBOX|in:1,2,3'] ['id2' => 1]

デフォルトの in

まぁ確かに、INじゃないものを弾いています。0 はもちろん、NULL もダメです。

結果 Rule 検査対象
🔴 ['id' => 'in:1,2,3'] ['id' => 1]
['id' => 'in:1,2,3'] ['id' => 0]
['id' => 'in:1,2,3'] ['id' => null]
🔴 ['id' => 'in:1,2,3'] []

nullable | in

NULLを許可するには nullable を使う、というのもまぁOK。
0 はダメです。0とNULLは違う。0という選択肢は用意してないのでこれもOK。

結果 Rule 検査対象
🔴 ['id' => 'nullable|in:1,2,3'] ['id' => 1]
['id' => 'nullable|in:1,2,3'] ['id' => 0]
🔴 ['id' => 'nullable|in:1,2,3'] ['id' => null]
🔴 ['id' => 'nullable|in:1,2,3'] []

required_unless | in

問題の required_unless はこのような結果です。

理想 in 結果 Rule 検査対象
🔴 🔴 🔴 ['id' => 'required_unless:id2,0|in:1,2,3'] ['id2' => 0, 'id' => 1]
['id' => 'required_unless:id2,0|in:1,2,3'] ['id2' => 0, 'id' => 0]
🔴 ['id' => 'required_unless:id2,0|in:1,2,3'] ['id2' => 0, 'id' => null]
🔴 🔴 🔴 ['id' => 'required_unless:id2,0|in:1,2,3'] ['id2' => 0]
🔴 🔴 🔴 ['id' => 'required_unless:id2,0|in:1,2,3'] ['id2' => 1, 'id' => 1]
['id' => 'required_unless:id2,0|in:1,2,3'] ['id2' => 1, 'id' => 0]
['id' => 'required_unless:id2,0|in:1,2,3'] ['id2' => 1, 'id' => null]
🔴 🔴 ['id' => 'required_unless:id2,0|in:1,2,3'] ['id2' => 1]

「なにがしたい?」のときのケースは①と②です。
①で弾かれたのはOKでしたが、②でも弾かれてしまったのが想定外だったと。

これは、required_if系ルールは、 発動していないときは知ったこっちゃない! からです。当たり前といえば当たり前なのですが、逆に期待していた動きは 発動していない(requiredじゃないとき)はNULLを許可してほしい です。

また、③のケースは、id = 1 のとき、つまり 0 じゃない = required が発動している時も結果がズレます。主オプションが有効のときには従属オプションが入力必須になりますが、従属オプションは「変更しない」ということもできるので、そこまで求めていません(後ほど見直します)。

required | in

required を見てみます。ばばん。

inのみ 結果 Rule 検査対象
🔴 🔴 ['id' => 'required|in:1,2,3'] ['id' => 1]
['id' => 'required|in:1,2,3'] ['id' => 0]
['id' => 'required|in:1,2,3'] ['id' => null]
🔴 ['id' => 'required|in:1,2,3'] []

reqiuied は発動すると、未セットを殺しにかかります(NULLセットも殺されているのですが元々なのでここでは変わらない結果です)。
厳しいです。

required | nullable | in

この required さん。実際にどんな動作をしているのかと言うと、実は (発動した時に)無いことを許可しない!のであって、あるときは知ったこっちゃない!し、発動しなかったときは知ったこっちゃないのです。指定がないときやNULLセットされていると喜んで殺しにかかりますが、それ以外の場合は後続の別ルールに丸投げしています。ということは、nullable と組み合わせたら、required が発動したときにだけうまいこと機能しないかなーと思ってやってみた結果(今思えばそんなワケないけど)。

required nullable required|nullable Rule 検査対象
🔴 🔴 🔴 ['id' => 'required|in:1,2,3'] ['id' => 1]
['id' => 'required|in:1,2,3'] ['id' => 0]
🔴 🔴 ['id' => 'required|in:1,2,3'] ['id' => null]
🔴 🔴 ['id' => 'required|in:1,2,3'] []

nullable の圧勝です。nullableがこんなに強いとは思いませんでした。
実際のコードを追うとわかるのですが、nullableは他のルールとは別次元の最優先実装されていて、nullableが指定されている場合、他にどんな条件があっても、NULLや未セットを通過させます。最強のルールです。

nullable_if | in

というわけで、結局欲しかったものは「特定のケースのみNULLを許可する」という当初の字面通りの機能を実装するしかないのかなーとググって教えていただきました。

結果 Rule 検査対象
🔴 ['id' => 'nullable_if:id2,0|in:1,2,3'] ['id2' => 0, 'id' => 1]
['id' => 'nullable_if:id2,0|in:1,2,3'] ['id2' => 0, 'id' => 0]
🔴 ['id' => 'nullable_if:id2,0|in:1,2,3'] ['id2' => 0, 'id' => null]
🔴 ['id' => 'nullable_if:id2,0|in:1,2,3'] ['id2' => 0]
🔴 ['id' => 'nullable_if:id2,0|in:1,2,3'] ['id2' => 1, 'id' => 1]
['id' => 'nullable_if:id2,0|in:1,2,3'] ['id2' => 1, 'id' => 0]
['id' => 'nullable_if:id2,0|in:1,2,3'] ['id2' => 1, 'id' => null]
🔴 ['id' => 'nullable_if:id2,0|in:1,2,3'] ['id2' => 1]

required_unless | nullable_if | in

さらに。先程サラッと流しましたが、一番最後の条件「主オプションが有効のケースで、従属オプションが未セットでもいいのか?」という問題。デフォルトで値がセット済で、それを更新しないというケースがあると言ってみましたが、それって言い訳に近く、実際のUIを見ても、従属オプションななにか1つ選択されていないと正しく機能しそうにありません。
これを実現するには、required_unlessを組み合わせて使います。

結果 Rule 検査対象
🔴 ['id' => 'required_unless:id2,0|nullable_if:id2,0|in:1,2,3'] ['id2' => 0, 'id' => 1]
['id' => 'required_unless:id2,0|nullable_if:id2,0|in:1,2,3'] ['id2' => 0, 'id' => 0]
🔴 ['id' => 'required_unless:id2,0|nullable_if:id2,0|in:1,2,3'] ['id2' => 0, 'id' => null]
🔴 ['id' => 'required_unless:id2,0|nullable_if:id2,0|in:1,2,3'] ['id2' => 0]
🔴 ['id' => 'required_unless:id2,0|nullable_if:id2,0|in:1,2,3'] ['id2' => 1, 'id' => 1]
['id' => 'required_unless:id2,0|nullable_if:id2,0|in:1,2,3'] ['id2' => 1, 'id' => 0]
['id' => 'required_unless:id2,0|nullable_if:id2,0|in:1,2,3'] ['id2' => 1, 'id' => null]
['id' => 'required_unless:id2,0|nullable_if:id2,0|in:1,2,3'] ['id2' => 1]

補足

nullable_unless にしたい場合は、最後の条件をひっくり返すだけです。

        if (! in_array($data, $values)) {
            // マッチしなかったら nullable ルールを追加
            $validator->mergeRules( $attribute, [$attribute => 'nullable'] );
            return true;
        }
        // hit
        return true;

感想

こんな苦労をするくらいなら、最初からすっぱりとクロージャーかRuleクラスで書いたほうがよっぽどエレガント。

こんな記事も書いています

Laravelのちょっとマニアックな視点から、誰も書かない記事を書いています(笑
合わせてご覧いただけると幸いです(^^)

17
15
0

Register as a new user and use Qiita more conveniently

  1. You get articles that match your needs
  2. You can efficiently read back useful information
  3. You can use dark theme
What you can do with signing up
17
15