天然パーマです。

ORMにValidation機構を持たせる

ユーザーからのPOST等された入力値の妥当性をチェックする Validation をどこでやるか問題が個人的にありまして〜、DBを使わないケースならばいわゆるFomrValidator::*を使ってControllerでやればいいのですが、Modelを経由するようなアプリだとControllerだけじゃ不安よねぇ〜、Modelだけ使う時もあるし、Model単体のテストで再現出来ないよね〜なんて思ってます。で、実際の実装をControllerではFormValidator::Lite、Modelの一部にData::Validatorを使っているのですが、なんかコレも効率悪い感じしてたんで、ちょいと実験的に理想の一つを実装してみました。

こんな条件です。

  • エラーメッセージを簡単に設定したいのでValidationモジュールにはFormValidator::Liteを使う
  • 色々錯誤していたらORMの段階でValidationしてResultオブジェクトを返すってのがいいのではないか
  • Resultオブジェクトではhas_error/error_messagesメソッドをはやしてControllerで扱いやすくする
  • Validationが通ればentryメソッドで生成されたORMのオブジェクトを取得出来る
  • WAFはMojolicious、ORMにはTengを使う前提で書いてみる

するとController側はこんな風に書ける。

sub post {
    my $self = shift;
    my $user = $self->stash->{user};
    return $self->render_not_found unless $user;
    my $result = $self->model('Entry')->create({
        user_id => $user->id,
        title => $self->req->param('title') || '',
        body => $self->req->param('body') || '',
    });
    if($result->has_error){
        $self->stash->{error_messages} = $result->error_messages;
        return $self->render('/entry/create');
    }
    $self->redirect_to('/entry/' . $result->entry->id);
}

$self->model('Entry')ってのはMyApp::Model::Entryを呼び出しすショートカットなんだけど、createメソッドの返り値が例のResultオブジェクトになっている。

Model側はもちろん他の処理も入るけど最小限これでイケる。

sub create {
    my ($self, $args) = @_;
    my $result = $self->db->insert('entry', { 
        title => $args->{title},
        body => $args->{body},
        user_id => $args->{user_id}
    });
    return $result;
}

肝心なのは通常「use parent 'Teng'」するMyApp::DBモジュール。これをちょいと拡張する。

package MyApp::DB;
use Mouse;
use String::CamelCase qw//;
use Module::Load qw//;
use MyApp::DB::Result;

extends 'Teng';

sub insert {
    my ($self, $table_name, $args, $prefix) = @_;
    my $class = "MyApp::Form::" . String::CamelCase::camelize($table_name);
    Module::Load::load($class);
    my $form = $class->new;
    my $validator = $form->check($args);
    if($validator->has_error) {
        my $result = MyApp::DB::Result->new(
            has_error => 1,
            error_messages => [$validator->get_error_messages()]
        );
        return $result;
    }
    my $entry = $self->SUPER::insert( $table_name, $args, $prefix );
    my $result = MyApp::DB::Result->new( entry => $entry );
    return $result;
};
__PACKAGE__->meta->make_immutable();
1;

MyApp::DB::Resultはこんなん。

package MyApp::DB::Result;
use Mouse;

has error_messages => ( is => 'rw', isa => 'ArrayRef', default => sub { [] } );
has has_error => ( is => 'rw', isa => 'Bool', default => 0 );
has entry => ( is => 'rw', isa => 'Object');

__PACKAGE__->meta->make_immutable();
1;

その他にFormValidator::Liteを呼び出すためのMyApp::Formと個別のルールが書かれたMyApp::Form::Entryなどが存在する。以下がMyApp::Formで親クラス。FormValidator::Liteに渡す際、パラメータ系の互換にするめにMojo::Parametersを暫定的に使ってます。

package MyApp::Form;
use Mouse;
use Mojo::Parameters;
use FormValidator::Lite;
FormValidator::Lite->load_constraints(qw/Japanese/);

sub validator {
    my ($self, $args) = @_;
    my $params = Mojo::Parameters->new(%$args);
    my $validator = FormValidator::Lite->new($params);
    $validator->load_function_message('ja');
    return $validator;
}

__PACKAGE__->meta->make_immutable();
1;

子にあたるMyApp::Form::Entryにはルールが存在している。モジュールにべた書きしてます。

package MyApp::Form::Entry;
use Mouse;
extends 'MyApp::Form';
use utf8;

sub check {
    my ($self, $args) = @_;
    my $v = $self->validator($args);
    $v->set_param_message(
        title => 'タイトル',
        body => '本文',
        user_id => 'ユーザーID'
    );
    my $res = $v->check(
        title => ['NOT_NULL', [qw/LENGTH 1 100/]],
        body => [qw/NOT_NULL/],
        user_id => [qw/INT/]
    );
    return $v;
}

__PACKAGE__->meta->make_immutable();
1;

ORMべったりでそもそもメソッド上書きしているけど、insertじゃない名前にしたりルールが存在しない場合は通常動作させるとか... も含めてこれはアリな気がするぞ... もしくはORMの層じゃなくてModelのとこで書くのもいいし。

他にモデルやDB層でいい感じのValidationを実装している方がいたら教えて欲しいです!