非ActiveRecord::Baseなクラスからvalidationを使ってみました

Railsの便利機能の中でもかなり注目度の高いActiveRecord::Base#validates_*によるバリデーションですが、これをDBに保存する場面以外でもつかえないか、というのが今回のトピックです。
ユーザからの入力をDBではなく通常のファイルや帳票に落としたり、単に画面に表示させたり、他のWebサービスに送りつけたりする場面で、ARのvalidationが使えると便利だろうなぁ、と。

DBが必須になる*1validates_associatedとvalidates_uniqness_of以外についてはうまく動かせましたのでメモを。

実際のブツはこちらからどうぞ。

2006/05/24 23:00追記

id:babieさんからのご指摘をうけ、アーカイブ内のソースのtypoを修正しました。
あまりにしょーもないtypoなので、なかなか恥ずかしいです。

まずは動作を

サンプルクラスの動作
SampleAccount = Struct.new(:username, :email, :balance)

SampleAccount.class_eval do
  include NotOnRails::ValidationsAdapter

  validates_presence_of      :username, :email
  validates_exclusion_of     :username, :in => ["admin"]
  validates_numericality_of  :balance,  :only_integer => true
  validates_confirmation_of  :email
  validates_format_of        :email, :with => /^([^@\s]+)@((?:[-a-z0-9]+\.)+[a-z]{2,})$/i

  def save_method
    puts " *SAVE METHOD CALLED!(detail below..)* "
    puts self.inspect
    @new_record = false
    true
  end
end

ここで、save_methodはsaveしたときに呼び出される動作を記述します。名前のまんまですが。冒頭の文脈ですと、このsave_methodをオーバーライドして帳票出力なんかをします。

validateしてみる(validateに失敗する場合)
  bad = SampleAccount.new
  bad.username = "admin"
  bad.balance  = "*not a number*"
  bad.email    = "moronatural_at_gmail.com"
  bad.email_confirmation = "moro_at_be.to"
  unless bad.save
    $stderr.puts "*VALIDATION ERROR*"
    bad.errors.each{|col,err| puts "  - #{col.to_s.humanize} #{err}" }
  end

実行結果は以下のようになります。

 *VALIDATION ERROR*
  - Username is reserved
  - Balance is not a number
  - Email is invalid
  - Email doesn't match confirmation

validates_presence_ofやvalidates_format_ofなんかが効いているのがわかると思います。ちなみにvalidate_*した結果のエラーはAR::Baseを継承したクラスと同様にerrorsで取り出せるようになります。

validateしてみる(validateに成功する場合)

今度はちゃんとした値をセットしてみます。

  good = SampleAccount.new
  good.username  = "morohashi"
  good.balance   = "12345"
  good.email     = "moronatural@gmail.com"
  good.email_confirmation = "moronatural@gmail.com"
  good.save!

結果は以下のような感じです。上のsave_methodで定義したな処理が実行されます。

 *SAVE METHOD CALLED!(detail below..)*
 #<struct SampleAccount username="morohashi", email="moronatural@gmail.com", balance="12345">

いい感じですね。

ValidationsAdapterモジュールの仕組み

基本はActiveRecord::Validationsをincludeするだけなんですが、その際にalias_methodでsaveやsave!に別名が付けられます。その時点で同名のメソッドが定義されていないとエラーとなりますので、以下のメソッドを定義しておきます。

  • save
  • save!
  • update_attribute

また、内部的にnew_record?メソッドが呼ばれますのでそれも定義しておく必要があります。

  def new_record? ; @new_record ; end
  def save  ; save_method ; end
  def save! ; raise "override me." unless save_method ; end
  def update_attribute; update_method ; end

上記のような感じで、saveとsave!の実体はsave_methodを、update_attributeの実体はupdate_methodを、それぞれmix-inしたクラス内で定義します。

さらに、validates_numericality_ofを用いる場合には、内部で呼ばれている*_before_type_castも定義する必要があります。拙作ライブラリではDynamicMethodProxyモジュールにてmethod_missingをオーバーライドし、同メソッドを動的に定義しています。

  def method_missing(method, *args)
    case method.to_s
    when /(\w+)_before_type_cast/ 
      before_type_cast($1.to_sym)
    else
      super
    end
  end

  private
  def before_type_cast(attr_name); self.__send__(attr_name); end

主な処理はこんな感じで。あとはメインとなるValidationsAdapterモジュールがincludeされるタイミングでごにょごにょとしてActiveRecord::Validationsを定義しています。
また、validationのエラーメッセージを表示させるためにself.human_attribute_nameメソッドが呼ばれます。とりあえずActiveRecord::Base.human_attribute_nameに委譲しています。

module NotOnRails::Validations
  def self.included(base)
    base.extend(ActiveRecord::Validations::ClassMethods)
    base.extend(NotImplementedValidations)
    base.class_eval do 
      include ActiveRecord::Validations
      def self.human_attribute_name(attribute_name)
        ActiveRecord::Base.human_attribute_name(attribute_name)
      end
    end
  end
end

プログラム内からの使い方

こちらからアーカイブをダウンロードしてください。Railsプラグインの形式になってはいますが、要はlib/validations_adapter.rbをrequireし、NotOnRails::ValidationsAdapterモジュールを通常どおりインクルードすれば問題なく使えるはずです。

以上、感想やバグ報告などお待ちしています。

*1:と言うかDBに保存する場面でないと無意味だろうと思われる