[CakePHP]validationのメッセージ出力をDRYにしてみる

1.2系で色々と便利になっているvalidation機能ですが、エラーメッセージ出力については「DRYじゃない」と感じる点があります。
今回はそれを解消することを試みてみました。(バージョンはBeta 1.2.0.6311)

「DRYじゃない」と感じる点

同じruleのメッセージを何度も書かなくてはいけない
例えば、同じruleが複数登場する場合、モデル内の$validate配列が

<?php
var $validate = array(
'field1' => array(
array('rule' => VALID_NOT_EMPTY , 'message' => '必ず入力してください。'),
array('rule' => array('minLength', 5), 'message' => '5文字以上で入力してください。' ),
array('rule' => array('alphaNumeric'), 'message' => '半角英数字で入力してください。')
),
'field2' => array(
array('rule' => VALID_NOT_EMPTY , 'message' => '必ず入力してください。'),
array('rule' => array('minLength', 8), 'message' => '8文字以上で入力してください。' ),
array('rule' => array('alphaNumeric'), 'message' => '半角英数字で入力してください。')
).....
);
?>

といった感じになり、同じ(または似た)メッセージを何度も書くと思います。
また、違うモデルにも同じメッセージを何度も何度も書かないといけないのは非常に煩わしいです。

複数のruleでエラーが発生しても、1つのメッセージしか出力されない
上に挙げた$validate配列の場合、field1が空でpostされると、3つのrule(VALID_NOT_EMPTY, minLength, alphaNumeric)全てを満たさないのですが、

といったように1つのエラーメッセージしか出力されません。
Modelクラスのソースを見てみたところ、3つのruleのvalidationが上から順に全て実行されているのですが、エラーが発生するたびにそのフィールドのエラーメッセージを上書きしているため、一番最後にエラーが発生したrule(alphaNumeric)のメッセージが出力されるような作りになっていました。
これは「DRYじゃない」というよりは、ユーザーの利便性を考えると、入力値が満たしていない情報は全て教えてあげた方が親切だと思います。

上記の問題を解消するために
以下のような処理方式を考えました。

個別の設定無しでruleごとに対応したデフォルトメッセージを出力させる
ruleごとに対応したデフォルトメッセージを定義しておき、エラー発生時にはそのメッセージを出力するような仕組みにしました。
また、引数を持つrule(例えばminlength等)にも適切なメッセージ(「○文字以上で入力してください」など)を出力できるように、メッセージの定義にはprintf系関数で使える変換指定子(%s等)を使えるようにしました。
こうしておけば、大抵の場合は個別にメッセージを書かなくても良いと思います。
但し、メッセージを個別にセットする場合もあるので、従来どおり$validata配列のなかに'message'をセットすると、そっちを優先するようにします。

この処理はAppModelクラスにてModelクラスのinvalidFieldsメソッドをオーバーライドすることで実現しました。

1つのフィールドで複数のエラーが発生した場合、全てのメッセージを出力する
先に述べたように複数のエラーがあった場合、最後のエラーで上書きされてしまっています。
ここを上書きではなく、適当な区切り文字でメッセージを連結して溜め込んで、画面に出力する時にその区切り文字で分割して出力を行う、という処理にしました。

この処理は、連結部分はAppModelクラスにてModelクラスのinvalidateメソッドをオーバーライド、表示部分はAppFormHelperクラスにてFormHelperクラスのerrorメソッドをオーバーライドすることで実現しました。
AppFormHelperクラスを使うと、テンプレートでは全て$form⇒$appFormになるので、それが嫌な場合は直接FormHelperクラスを書き換えるのもアリかも知れません。

上記を実装した「ソースコード」は長いので最後に回して、先にこれを使ったときの「サンプル」をいくつか挙げてみます。

サンプル
モデル内の$validate配列が以下の場合で、

<?php
var $validate = array(
// 'message'の指定なし
'field1' => array(
array('rule' => VALID_NOT_EMPTY),
array('rule' => array('minLength', 5)),
array('rule' => array('alphaNumeric'))
),
// 'message'の指定あり
'field2' => array(
array('rule' => VALID_NOT_EMPTY, 'message' => 'ここは絶対入力してください!!!')
).....
);
?>

テンプレート内では

<?php
echo $appForm->input('field1');
echo $appForm->input('field2');
?>

となっている場合を例に挙げます。

field1に何も入力しない場合

全てのエラーメッセージが表示されます。(行間はcssで調整できます)

field1に'aaa'を入力した場合

'minLength'のエラーメッセージだけが表示されます。

field2に何も入力しない場合

デフォルトのメッセージではなく、$validate配列に設定した'message'が表示されます。

まとめ
サンプルの通り、やりたかった

      • 毎度毎度のエラーメッセージの設定の回避
      • 複数エラー発生時の全てのメッセージの出力

は実現でき、自分の環境では調子よく動作しています。
サンプルにはないですが、AppModelに独自のvalidationのメソッドがある場合も、メッセージ定義を追加すればメッセージを毎回指定する必要は無くなります。
自分的にはCakePHPに対する不満が1つ解消されたので、同じような不満を抱いている方は、もし良ければ使ってみてください。

最後にソースコード。
ソースコード
【app/config/messages.php】…ruleに対応したメッセージの設定

<?php
class Messages {
/**
* メッセージの区切り文字
*
* @var string
*/
static $separator = "###MESSAGE_SEPARATOR###";

/**
* validationルールに対応するエラーメッセージ
*
* @var array
*/
static $error = array(
VALID_NOT_EMPTY => '必ず入力してください。',
VALID_NUMBER => '半角数字を入力してください。',
VALID_EMAIL => 'メールアドレス形式で入力してください。',
VALID_YEAR => '年(1000~2999)を入力してください。',
'alphaNumeric' => '半角英数字を入力してください。',
'between' => '%1$d文字以上%2$d文字以内の半角数字を入力してください。',
'blank' => '空でなければなりません。',
'cc' => 'クレジットカード番号として正しくありません。',
'comparison' => '入力値%1$s%2$sを満たす値を入力してください。',
'custom' => '入力値が正しくありません。',
'date' => '日付形式(%1$s)で入力してください。',
'decimal' => '小数点第%1$d位までの半角数字を入力してください。',
'email' => 'メールアドレス形式で入力してください。',
'equalTo' => '入力値が"%1$s"と一致しません。',
'extension' => '拡張子が正しくありません。',
'file' => '', // 実装されていない
'ip' => 'IPアドレス形式で入力してください。',
'minLength' => '%1$d文字以上で入力してください',
'maxLength' => '%1$d文字以内で入力してください',
'money' => '入力値が正しくありません。',// 通貨?
'multiple' => '', // 実装されていない
'numeric' => '半角数字を入力してください。',
'phone' => '電話番号形式で入力してください。',
'postal' => '郵便番号形式で入力してください。',
'range' => '%1$dより大きく%2$dより小さい半角数字を入力してください。',
'ssn' => 'ソーシャルセキュリティナンバー形式で入力してください。',
'url' => 'URL形式で入力してください。',
'userDefined' => '入力値が正しくありません。',
);
}
?>

【app/models/app_model.php】

<?php
config('messages');
class AppModel extends Model {

/**
* invalidFieldsメソッドのオーバーライド
*
* @param array $data Parameter usage is deprecated, set Model::$data instead
* @return array Array of invalid fields
* @access public
*/
function invalidFields($data = array()) {
if (!empty($this->behaviors)) {
$behaviors = array_keys($this->behaviors);
$ct = count($behaviors);
for ($i = 0; $i < $ct; $i++) {
if ($this->behaviors[$behaviors[$i]]->beforeValidate($this) === false) {
return $this->validationErrors;
}
}
}

if (!$this->beforeValidate()) {
return $this->validationErrors;
}

if (empty($data)) {
$data = $this->data;
} else {
trigger_error(__('(Model::invalidFields) Parameter usage is deprecated, set the $data property instead', true), E_USER_WARNING);
}

if (!isset($this->validate) || empty($this->validate)) {
return $this->validationErrors;
}

if (isset($data[$this->alias])) {
$data = $data[$this->alias];
}

$Validation =& Validation::getInstance();
$exists = $this->exists();

foreach ($this->validate as $fieldName => $ruleSet) {
if (!is_array($ruleSet) || (is_array($ruleSet) && isset($ruleSet['rule']))) {
$ruleSet = array($ruleSet);
}

foreach ($ruleSet as $index => $validator) {
if (!is_array($validator)) {
$validator = array('rule' => $validator);
}

$default = array('allowEmpty' => null, 'required' => null, 'rule' => 'blank', 'last' => false, 'on' => null);
$validator = array_merge($default, $validator);

if (is_array($validator['rule'])) {
$rule = $validator['rule'][0];
unset($validator['rule'][0]);
$ruleParams = array_merge(array($data[$fieldName]), array_values($validator['rule']));
$messageParams = array_values($validator['rule']);
} else {
$rule = $validator['rule'];
$ruleParams = array($data[$fieldName]);
$messageParams = array();
}

if (isset($validator['message'])) {
$message = $validator['message'];
} elseif ( array_key_exists($rule, Messages::$error ) ) {
$message = Messages::$error[$rule];
$validator['message'] = $message;
} else {
$message = __('This field cannot be left blank', true);
}

if(isset($validator['message_params'])) {
$messageParams = $validator['message_params'];
}

if (empty($validator['on']) || ($validator['on'] == 'create' && !$exists) || ($validator['on'] == 'update' && $exists)) {
if ((!isset($data[$fieldName]) && $validator['required'] === true) ||
(isset($data[$fieldName]) && (empty($data[$fieldName]) && !is_numeric($data[$fieldName])) && $validator['allowEmpty'] === false)) {
$this->invalidate($fieldName, $message, $messageParams);
if ($validator['last']) {
break;
}
} elseif (array_key_exists($fieldName, $data)) {
if (empty($data[$fieldName]) && $data[$fieldName] != '0' && $validator['allowEmpty'] === true) {
continue;
}

$valid = true;
$msg = null;

if (method_exists($this, $rule) || isset($this->__behaviorMethods[$rule]) || isset($this->__behaviorMethods[strtolower($rule)])) {
$ruleParams[] = array_diff_key($validator, $default);
$ruleParams[0] = array($fieldName => $ruleParams[0]);
$valid = call_user_func_array(array(&$this, $rule), $ruleParams);
} elseif (method_exists($Validation, $rule)) {
$valid = call_user_func_array(array(&$Validation, $rule), $ruleParams);
} elseif (!is_array($validator['rule'])) {
$valid = preg_match($rule, $data[$fieldName]);
}

if (!$valid) {
if (!isset($validator['message'])) {
if (is_string($index)) {
$validator['message'] = $index;
} else {
$validator['message'] = ife(is_numeric($index) && count($ruleSet) > 1, ($index + 1), $message);
}
}

$this->invalidate($fieldName, $validator['message'], $messageParams);

if ($validator['last']) {
break;
}
}
}
}
}
}
return $this->validationErrors;
}

/**
* invalidateのオーバーライド
*
* @param string $field The name of the field to invalidate
* @param string $value Name of validation rule that was not met
* @access public
*/
function invalidate($field, $value = null, $messageParams = array()) {
if (!is_array($this->validationErrors)) {
$this->validationErrors = array();
}
if (empty($value)) {
$value = true;
}
if (count($messageParams) > 0) {
$value = vsprintf($value, $messageParams);
}
// 既にセットしてある場合は、区切り文字を挟んで連結する
if (!empty($this->validationErrors[$field])) {
$this->validationErrors[$field] .= Messages::$separator . $value;
} else {
$this->validationErrors[$field] = $value;
}
}
}
?>

【app/views/helpers/app_form.php】

<?php
class AppFormHelper extends FormHelper {
/**
* errorメソッドのオーバーライド
*
* @param string $field A field name, like "Modelname.fieldname", "Modelname/fieldname" is deprecated
* @param string $text Error message
* @param array $options Rendering options for wrapper tag
* @return string If there are errors this method returns an error message, otherwise null.
* @access public
*/
function error($field, $text = null, $options = array()) {
$this->setEntity($field);
$options = array_merge(array('wrap' => true, 'class' => 'error-message', 'escape' => true), $options);

if ($error = $this->tagIsInvalid()) {
if (is_array($text) && is_numeric($error) && $error > 0) {
$error--;
}
if (is_array($text) && isset($text[$error])) {
$text = $text[$error];
} elseif (is_array($text)) {
$text = null;
}

if ($text != null) {
$error = $text;
} elseif (is_numeric($error)) {
$error = sprintf(__('Error in field %s', true), Inflector::humanize($this->field()));
}
if ($options['escape']) {
$error = h($error);
unset($options['escape']);
}
$messages = explode(Messages::$separator, $error);
$output = '';
foreach ( $messages as $message){
if ($options['wrap'] === true) {
unset($options['wrap']);
$output .= $this->output(sprintf($this->Html->tags['error'], $this->_parseAttributes($options), $message));
$options['wrap'] = true;
} else {
$output .= $message;
}
}
return $output;
} else {
return null;
}
}
}
?>

【app/controllers/app_controller.php】

<?php
class AppController extends Controller {

var $helpers = array('Html', 'Form', 'AppForm');

}
?>