FuelPHPで別フィールドを参照するバリデーションルール

はじめに

このエントリーは、FuelPHP Advent Calendar 2015の11日目です。

FuelPHPのValidationクラスは、用意されたバリデーションルール以外にも独自のルールを簡単に追加することができます。

今までいくつも作ったことがありますが、今回ちょっとだけハマったのでコードを読んで理解した内容を残しておきます。

バージョン

FuelPHP 1.7.2

追加したいルール

2つのフィールドのうち、どちらか一方だけ必須入力としたい。*1
requred_with(ある項目が入力ありなら必須)の変形みたいな感じ。

他にも、選択肢の中からN個以上必須とか、ある項目の入力内容で次の項目のバリデーションが異なるとか、検査のために別のフィールドの内容が必要になるパターン全般に共通です。

うまくいかなかったバージョン

バリデーションメソッド

<?php
public static function _validation_required_which($val, $field)
{
  //現在アクティブなValidationオブジェクトのinput配列を取得
  $input = Validation::active()->input();

  if (!array_key_exists($field , $input)) {
    return false; //ここに引っかかった
  }

  //両方からっぽ
  if (Validation::_empty($input[$field]) and Validation::_empty($val)) {
    return false;
  }

  //両方指定あり
  if (!Validation::_empty($input[$field]) and !Validation::_empty($val)) {
    return false;
  }

  return true;
}

呼び出し側(抜粋)

<?php
$valid = Validation::forge();

$valid->add_callable('Util_Validations');
$valid->add('hoge', 'HOGE')
->add_rule('required_which', 'fuga');
$valid->add('fuga', 'FUGA')
->add_rule('required_which', 'hoge');

if (!$valid->run()) {
  $error = array();

  foreach ($valid->error() as $key => $e) {
    $error[$key] = array(
        'message' => $e->get_message(),
        'type'    => $e->rule
        );
  }

  Session::set('error', $error);
  return Response::redirect('/input');
}

結果

一つめのフィールドである hogeを検査した際に、 if (!array_key_exists($field , $input)) return false; に引っかかってしまいました。

つまり、比較対象である fuga が入力フォームの配列になかったということらしい。

念のため Input::post を確認したところ問題なく取得。

原因

結論としては Validation::run() の仕様を誤解していたためでした。

すべてのフィールドを追加したら、バリデーションを実行することが出来ます。 デフォルトでは $_POST ですが、入力を与えることで、上書きや拡張することが出来ます。 Validation - クラス - FuelPHP ドキュメント

これを読んで、引数無しで Validation::run() を実行すると $_POST、つまり Input::post を渡したのと同等になると理解したのですが、実際には微妙に違っていて、検査するフィールドごとにInput::postから取得していました。

自作メソッドでは Validation::input() を使って input 配列を取得しているのですが、一つめのフィールド hoge の検査時点では hoge 自身しか入っていませんでした。

コードを読んでみる

Validation::run() (抜粋)

<?php
public function run($input = null, $allow_partial = false, $temp_callables = array())
{
  //省略

  $this->validated = array();
  $this->errors = array();
  $this->input = $input ?: array(); // ここが array() ではなく、Input::post だと思ってた
  $fields = $this->field(null, true);
  foreach($fields as $field) // add_field()やadd() したフィールドを順に処理
  {
    static::set_active_field($field);

    // convert form field array's to Fuel dotted notation
    $name = str_replace(array('[', ']'), array('.', ''), $field->name);

    $value = $this->input($name); // input配列から値を取得、配列内になかったらここで追加
    if (($allow_partial === true and $value === null)
        or (is_array($allow_partial) and ! in_array($field->name, $allow_partial)))
    {
      continue;
    }
    try
    {
      foreach ($field->rules as $rule)
      {
        $callback  = $rule[0];
        $params    = $rule[1];
        $this->_run_rule($callback, $value, $params, $field);
      }
      if (strpos($name, '.') !== false)
      {
        \Arr::set($this->validated, $name, $value);
      }
      else
      {
        $this->validated[$name] = $value;
      }
    }
    catch (Validation_Error $v)
    {
      $this->errors[$field->name] = $v;

      if($field->fieldset())
      {
        $field->fieldset()->Validation()->add_error($field->name, $v);
      }
    }
  }

  //省略
  return empty($this->errors);
}

Validation::input()

<?php
public function input($key = null, $default = null)
{
  if ($key === null)
  {
    return $this->input; // 引数なしだと現在のinput配列を返す
  }

  // key transformation from form array to dot notation
  if (strpos($key, '[') !== false)
  {
    $key = str_replace(array('[', ']'), array('.', ''), $key);
  }

  // if we don't have this key
  if ( ! array_key_exists($key, $this->input))
  {
    // it might be in dot-notation
    if (strpos($key, '.') !== false)
    {
      // check the input first
      if (($result = \Arr::get($this->input, $key, null)) !== null)
      {
        $this->input[$key] = $result;
      }
      else
      {
        $this->input[$key] =  $this->global_input_fallback ? \Arr::get(\Input::param(), $key, $default) : $default;
      }
    }
    else
    {
      // do a fallback to global input if needed, or use the provided default
      $this->input[$key] =  $this->global_input_fallback ? \Input::param($key, $default) : $default; //ここでInput::paramから取得
    }
  }

  return $this->input[$key];
}

global_input_fallbackというのは、Validationクラスの設定で、デフォルトtrue設定。
なので、input配列に存在しなかったらInput::paramから探します。*2

true にした場合で、入力された配列に値が見つからなかった場合、値は Input::param になります。 Validation - クラス - FuelPHP ドキュメント

呼び出し時にバリデーション対象を探して内部の配列に追加というのは、個人的にはちょっと行儀がよくない動きのように感じましたがどうなんでしょう。
使わない(ルールを適用しない)フィールドがたくさんある場合は効率いいのかな。

解決策その1

run(Input::post()) のように、引数に明示的にPOSTデータを渡してやれば実行時に全て保持しているので失敗しません。

でもルールによって呼び出し側で考慮しないといけないので、イマイチな気がします。

解決策その2

バリデーションメソッドを改良する。

<?php
public static function _validation_required_which($val, $field)
{
  $valid = Validation::active();

  if ($valid->_empty($valid->input($field)) and $valid->_empty($val)) {
    return false;
  }

  if (!$valid->_empty($valid->input($field)) and !$valid->_empty($val)) {
    return false;
  }

  return true;
}

Validation::active()->input(); としていたのを、Validation::active(); としてアクティブなインスタンスを取得して全てインスタンス経由で操作するように変更。

Validation::input() を引数指定で呼び出すことにより指定フィールドが input 配列になかった場合に Input::post を探しに行くようになり、期待どおりの動作が実現できました。

おまけ

Validationクラスで扱えるのは連想配列ということになっている*3のですが、今回の流れの途中でコードを読んでみたらちょっと違いました。

以下のサンプルの1つ目は動きますが、2つ目は add() した時に例外が発生します。

<?php
$valid = \Validation::forge();
$valid->add(1, 'hoge')->add_rule('required');
$valid->run(array('a', 'b'));
<?php
$valid = \Validation::forge();
$valid->add(0, '1st element')->add_rule('required');
$valid->run(array('a', 'b'));

これは、Validation::add() の実体である Fieldset::add()empty() を使ってチェックしているから。

<?php
public function add($name, $label = '', array $attributes = array(), array $rules = array())
{
  //省略

  if (empty($name) || (is_array($name) and empty($name['name'])))
  {
    throw new \InvalidArgumentException('Cannot create field without name.');
  }

  //省略

つまり、配列であっても0番目以外のフィールドには使えます。

バリデーションで連想配列ではなく配列を使いたいシーンがあるのかわからないですが、対策するならコアクラスを拡張して、 Validation::add()連想配列のみ通す、または Fieldset::add()Validation::_empty()みたいに 0 と '0' を通すのどちらかですかね。

参考

Validation クラス

*1:ラジオボタン使えというのは、とりあえず無しでおねがいします

*2:なんで Input::post じゃなくて Input::param なんでしょうね。

*3:1.7のドキュメントにはないけど、1.8のドキュメントのrun()の説明にある