FuelPHPのデフォルトバリデーションルールの仕様について調べた話

f:id:watass:20141214211533p:plain

最近仕事でFuelPHPを使う機会を得たのですが、FuelPHPのデフォルトで用意されているバリデーションルールで少しハマった部分があったため、戒めのためにまとめておきます。

意外とググッても情報が出てこない部分があって、公式ドキュメントはそれなりに充実しているものの、細かな仕様までは書かれていません。最終的にはコアのコードを読むのが一番だと気づいてようやくバリデーションルールの仕様がわかってきたのですが、やっぱり困ったらコアのコードを読む気持ちを持とうよという話です。

前提条件

使用するバージョンは以下の通り。

名前 バージョン
PHP 5.5
FuelPHP 1.7

発端になったバリデーション

今回の発端になったのは以下のような名前や年齢を受け取り、検証するフォームです。

f:id:watass:20151007225640p:plain

コードはこんな感じ

app/classes/controller/validation.php

<?php

class Controller_Validation extends Controller
{
    public function action_index()
    {
      $form = Fieldset::forge();
      $form->add('name', 'Name')->add_rule('required');
      $form->add('age', 'Age')
                   ->add_rule('required')
                   ->add_rule('numeric_between',0,200);
      $form->add('submit', '', array('type'=>'submit', 'value'=>'Submit'));

      if (Input::method() === 'POST') {
        $val = $form->validation();
        if ($val->run()) {
          print "入力を受付けました";
        } else {
          print "不正な入力です";
        }
        $form->repopulate();
      }
      $view = View::forge('index/get');
      $view->set_safe('form', $form->build('/validation/'));
      return $view;
    }
}

入力値をデータベースに入れることを考えるならば文字コードの検証や文字列の長さの検証、制御文字の有無など判定する必要があると思いますが、今回はサンプルなので最低限にしています。
デフォルトでFuelPHPに用意されているrequiredとnumeric_betweenを使用して、年齢には0歳から200歳までを受け入れるようにルール設定をし、判定結果を表示するだけです。

これで以下のように問題なくバリデーションが機能します。

f:id:watass:20151007230119p:plain

ところが、以下のような入力はパスしてしまいます。

f:id:watass:20151007230358p:plain

あれ!?numeric_betweenで0から200の数値しか受け入れていないはずなのに、なんで数値ですらない文字列がバリデーションパスしてるの?バグじゃん!!!
・・・まあそんなときは慌てず騒がず、コアの実装を確認してみましょう。

core/classes/validation.php (抜粋)

<?php
/* 中略 */
/**
 * Checks whether numeric input is between a minimum and a maximum value
 *
 * @param   string|float|int
 * @param   float|int
 * @param   float|int
 * @return  bool
 */
public function _validation_numeric_between($val, $min_val, $max_val)
{
	return $this->_empty($val) or (floatval($val) >= floatval($min_val) and floatval($val) <= floatval($max_val));
}

はい、原因はfloatvalですね。そもそもnumeric_betweenは評価される値に数値以外が来ることを想定していません。ちなみに、これはnumeric_minやnumeric_maxでも同じです。
PHPでは文字列をfloat型にキャストする場合、「一般的には」0になるので、0以上200以下の条件に合致するため、バリデーションをパスできたということですね。一般的に、と書いたのは文字列に数値を含む場合にその数値としてキャストされる場合があったりするので書いてあります。詳細は以下のページを見てください。

PHP: 文字列 - Manual

問題を解決するためには

上記のように文字列が入ってくることを許容できないのであれば、valid_stringにnumericを指定するようなルールを経由する必要があります。

<?php
/* 中略 */
$form->add('age', 'Age')
             ->add_rule('required')
             ->add_rule('valid_string','numeric')
             ->add_rule('numeric_between',0,200);

ただ、こうすると今度はマイナスの値がバリデーションをパスできなくなります。
なぜでしょう、迷ったらコアを読みます。

core/classes/validation.php (抜粋)

<?php
/* 中略 */
	/**
	 * Validate input string with many options
	 *
	 * @param   string
	 * @param   string|array  either a named filter or combination of flags
	 * @return  bool
	 */
	public function _validation_valid_string($val, $flags = array('alpha', 'utf8'))
	{
		if ($this->_empty($val))
		{
			return true;
		}

		if ( ! is_array($flags))
		{
			if ($flags == 'alpha')
			{
				$flags = array('alpha', 'utf8');
			}
			elseif ($flags == 'alpha_numeric')
			{
				$flags = array('alpha', 'utf8', 'numeric');
			}
			elseif ($flags == 'specials')
			{
				$flags = array('specials', 'utf8');
			}
			elseif ($flags == 'url_safe')
			{
				$flags = array('alpha', 'numeric', 'dashes');
			}
			elseif ($flags == 'integer' or $flags == 'numeric')
			{
				$flags = array('numeric');
			}
			elseif ($flags == 'float')
			{
				$flags = array('numeric', 'dots');
			}
			elseif ($flags == 'quotes')
			{
				$flags = array('singlequotes', 'doublequotes');
			}
			elseif ($flags == 'slashes')
			{
				$flags = array('forwardslashes', 'backslashes');
			}
			elseif ($flags == 'all')
			{
				$flags = array('alpha', 'utf8', 'numeric', 'specials', 'spaces', 'newlines', 'tabs', 'punctuation', 'singlequotes', 'doublequotes', 'dashes', 'forwardslashes', 'backslashes', 'brackets', 'braces');
			}
			else
			{
				return false;
			}
		}

		$pattern = ! in_array('uppercase', $flags) && in_array('alpha', $flags) ? 'a-z' : '';
		$pattern .= ! in_array('lowercase', $flags) && in_array('alpha', $flags) ? 'A-Z' : '';
		$pattern .= in_array('numeric', $flags) ? '0-9' : '';
		$pattern .= in_array('specials', $flags) ? '[:alpha:]' : '';
		$pattern .= in_array('spaces', $flags) ? ' ' : '';
		$pattern .= in_array('newlines', $flags) ? "\r\n" : '';
		$pattern .= in_array('tabs', $flags) ? "\t" : '';
		$pattern .= in_array('dots', $flags) && ! in_array('punctuation', $flags) ? '\.' : '';
		$pattern .= in_array('commas', $flags) && ! in_array('punctuation', $flags) ? ',' : '';
		$pattern .= in_array('punctuation', $flags) ? "\.,\!\?:;\&" : '';
		$pattern .= in_array('dashes', $flags) ? '_\-' : '';
		$pattern .= in_array('forwardslashes', $flags) ? '\/' : '';
		$pattern .= in_array('backslashes', $flags) ? '\\\\' : '';
		$pattern .= in_array('singlequotes', $flags) ? "'" : '';
		$pattern .= in_array('doublequotes', $flags) ? "\"" : '';
		$pattern .= in_array('brackets', $flags) ? "\(\)" : '';
		$pattern .= in_array('braces', $flags) ? "\{\}" : '';
		$pattern = empty($pattern) ? '/^(.*)$/' : ('/^(['.$pattern.'])+$/');
		$pattern .= in_array('utf8', $flags) || in_array('specials', $flags) ? 'u' : '';

		return preg_match($pattern, $val) > 0;
	}

numericを指定した場合には、フラグにnumericが立つのでpreg_matchに書けられる正規表現は/^['0-9']+$/になります。つまり、numericは負数を想定していないんですね。切ない。まぁマイナス記号は数字ではないですからね。

文字列は許可したくないけど、マイナス記号は受け入れたい場合には以下のように修正すればいけます。

<?php
/* 中略 */
$form->add('age', 'Age')
             ->add_rule('required')
             ->add_rule('valid_string',array('numeric','dashes'))
             ->add_rule('numeric_between',-100,200);

valid_stringには配列としてフラグ立てたものを与えれば判定ができます。
これで解決かとおもいきや、今度は「-100-」とか「_200」とかを受け入れられてしまいます。
数値を扱う際にfloatvalするとかの対応が考えられますが、やっぱりあんまり好ましくありませんので、結局自作バリデーションを使うしかなさそうです。

app/classes/validate/validation.php

<?php

class Validate_Validation
{
  public static function _validation_is_numeric($val)
  {
    return preg_match('/^\-*[0-9]+$/', $val) > 0;
  }
}

app/classes/controller/validation.php (抜粋)

<?php
/* 中略 */
$form->validation()->add_callable('Validate_Validation');
$form->add('age', 'Age')
             ->add_rule('required')
             ->add_rule('is_numeric')
             ->add_rule('numeric_between',-100,200);

これでようやく目的達成です。長かった・・・

ということでまとめた

そんなわけでnumeric_betweenに苦しめられたため、デフォルトのバリデーションルールに関しては読んで理解してまとめておくことにしました。こんな感じです。

ルール名 概要
required false,null,空文字,空配列以外ならばパス
match_value[$compare,$strict] PHPの比較。$strictがtrueならば===で判定される。$compareは配列でもOK。false,null,空文字,空配列ならばパスされる
match_pattern[$pattern] preg_matchで正規表現を判定する。false,null,空文字,空配列ならばパスされる
match_field[$field] 厳密な比較で指定されたPOSTのフィールドと一致するか確認する。メールアドレスの確認用入力などの検証に便利
macth_collection[$collection,$strict] in_arrayで配列に値が存在するか検証される。$strictはin_arrayに使用される。$collectionに配列が指定されない場合には、引数リストの配列を$collectionとして使用するため、match_valueっぽい振る舞いもできる
min_length[$length] 文字列の長さをmb_strlenで判定する。文字コードFuelPHPの設定値を使うため注意。等しい値は含む。false,null,空文字,空配列ならばパスされる
max_length[$length] 文字列の長さをmb_strlenで判定する。文字コードFuelPHPの設定値を使うため注意。等しい値は含む。false,null,空文字,空配列ならばパスされる
exact_length[$length] 文字列の長さをmb_strlenで判定する。文字コードFuelPHPの設定値を使うため注意。false,null,空文字,空配列ならばパスされる
vaild_email PHPのFILTER_VALIDATE_EMAILで判定される。false,null,空文字,空配列ならばパスされる
valid_emals[$separator] $separatorでexplodeされ、それぞれFILTER_VALIDATE_EMAILで判定される。false,null,空文字,空配列ならばパスされる
valid_url PHPのFILTER_VALIDATE_URLで判定される。false,null,空文字,空配列ならばパスされる
valid_ip PHPのFILTER_VALIDATE_IPで判定される。false,null,空文字,空配列ならばパスされる
valid_string[$flags] 正規表現を軸に入力フラグに対してpreg_matchで判定する。決まったパターンセットを引数にすることが一般的だが、配列でフラグを選択して渡すこともできる。false,null,空文字,空配列ならばパスされる
numeric_min[$min_val] >=で比較される。float型にキャストされるので注意。false,null,空文字,空配列ならばパスされる
numeric_max[$max_val] <=で比較される。float型にキャストされるので注意。false,null,空文字,空配列ならばパスされる
numeric_between[$min_val,$max_val] それぞれ>=と<=で比較される。float型にキャストされるので注意。false,null,空文字,空配列ならばパスされる
required_with[$field] POSTされた際にフィールドの値が存在するときにはrequiredみたいな振る舞い。セットで入力してもらいたい際に便利。フィールドの値が存在する基準となる判定はfalse,null,空文字,空配列でないこと
valid_date[$format,$strict] フォーマット指定時にはdate_parse_from_formatによって、指定なしの場合にはdate_parseによってパースできるかどうか。$strictをfalseにするとパース時のwarning_countを無視して判定する
valid_stringのパターンセット 対応するフラグ
alpha alpha,utf8
alpha_numeric alpha,utf8,numeric
specials specials,utf8
url_safe alpha,numeric,dashes
integer numeric
numeric numeric
float numeric,dots
quotes singlequotes,doublequotes
slashes forwardslashes,backslashes
all alpha,utf8,numeric,specials,spaces,newlines,tabs,punctuation,singlequotes, doublequotes,dashes,forwardslashes,backslashes,brackets,braces
valid_stringのフラグ 概要
alpha a-zA-Zをパターンに組み込む
uppercase alphaフラグと併用。立てるとa-zをパターンに組み込まない
lowercase alphaフラグと併用。立てるとA-Zをパターンに組み込まない
numeric 0-9をパターンに組み込む
specials [:alpha:]をパターンに組み込む
spaces 半角スペースをパターンに組み込む
newlines ¥r¥nをパターンに組み込む
tabs ¥tをパターンに組み込む
dots ドット記号をパターンに組み込む
commas コンマをパターンに組み込む
punctuation ".,!?:;&"をパターンに組み込む
dashes "_-"をパターンに組み込む
forwardslashes "/"をパターンに組み込む
backslashes "\"をパターンに組み込む
brackets ()をパターンに組み込む
braces {}をパターンに組み込む
utf8 preg_matchに/u修飾子を付加する

requiredの振る舞いを見れば納得な気もする

numeric_betweenなんだから、そもそも数値以外は弾けよ!といいたい気持ちにもなりますが、それをいったら他のvalid_stringとかだって検証対象の値がNULLだったりしたら弾いてほしいって話になりますよね。でもその辺はrequiredを入れないことでNULLの場合は問答無用で許可してほしいわけです。
numeric_betweenとかも同じ考えのもとにあると思ったほうがいいのかもしれません。でも、数値の範囲を指定しておいて、数値以外は許可したいケースってあんまりないような・・・

まぁ困ったときにはFuelPHPのバリデーションは簡単に拡張できるようになっているので、可読性とかの観点からも自作バリデーションを書くのはおすすめです。
デフォルトのルールで困ったときにも、FuelPHPのコアは普通にPHPで書かれているので、非常に読みやすいです。特にバリデーションメソッドはTrueを返すかFalseを返すか書いてあるだけでシンプルなので、一度読んでみてはいががでしょうか。

参考

Validation - クラス - FuelPHP ドキュメント
# 1.7公式のバリデーションルールに関する解説。網羅されていて非常によいです。
FuelPHPのバリデーションクラスについて。 - 新人Webエンジニアの記録。
# FuelPHPのバリデーションについてかなり詳しく調べられています。非常に参考になりました。