特定のカラムの編集を禁止したい場合のvalidation

あるテーブルのカラムがシステムのみによって編集される値で、ユーザから編集させたくない場合、そのvalidation が欲しいと思った。
例えば、はてなブックマークのエントリーのタイトルは、ユーザは編集できず、ページのtitle タグの内容で固定したいみたいな場合。
そんなん、attr_protected とか使えよって話なのですが、CSV を使っての編集みたいな場合、データをCSV でDL して、編集してUP というパターンがあると思います。その際に、編集不可能項目を編集しようとする人が1人はいる訳です。
注意書き書いたとしても、見落とす。で、「エラーでだせよヾ(`д´)ノシ」って怒られる訳なんです。はい。
そんな時にこのテクニックの出番。

ぱっと思いついたけど、実際にできなかった方法

  • 単純にvalidate メソッドを使っての実装はできない

これは、元々の値を参照できないからです。

def validate
# self.title とtitle は常に同じ値。self.title はDB の内容を参照できるということはない
  errors.add(:title, 'は編集できません') if title != self.title
end
  • errros.add + アクセサのオーバーライドでもできない
def title=(val)
  if !title.blank? && title != val
    errors.add(:title, 'は編集できません')
  else
    write_attribute(:title, val)
  end
end

これは、valiadte系 メソッド以外の場所でのerros.add は無効なため。

def valid?
  errors.clear # 他の場所でerros.add してもここで消される
 
  run_callbacks(:validate)
  validate
 
  if new_record?
    run_callbacks(:validate_on_create)
    validate_on_create
  else
    run_callbacks(:validate_on_update)
    validate_on_update
  end
 
  errors.empty?
end

rails 2.1 で実装されたDirty Trackingを使えばできる

Dirty Tracking の機能は、特定のカラムの値が変更されたかを調査できるメソッドです。

@entry.title #=> 'ほげほげ についてそろそろ言っておくか'
@entry.title_changed? #=> false
@entry.title = 'ふがふが についてそろそろ言っておくか'
@entry.title_changed? #=> true


これつかって、validate_on_update に編集禁止の旨を書けば、要求された機能を実装できます。

class Entry < ActiveRecord::Base
  def validate_on_update
    errors.add(:title, 'は編集できません') if title_changed?
  end
end

Rails のバージョンが2.1 未満の場合

今回、僕の場合も開発に使っているRails は1.0でしたので、上の方法がデフォルトでは使えません。この場合、changed? メソッドを2.1 からport します。
ちょっと変更して、編集を禁止するカラムを指定するようにしました。
(1.0 なのでalias_method_chain も使えない・・・)

  • dirty_tracking.rb
# Partial porting from Rails 2.1.0 active_record/dirty.rb

module DirtyTracking
  def self.included(c)
    c.extend ClassMethods
  end

  module ClassMethods
    def dirty_tracking(*attrs)
      attrs.each do |attr|
        class_eval <<-DEFINE
          def #{attr}_changed?
            attribute_changed?("#{attr}")
          end

          def #{attr}=(val)
            if !changed_attributes.include?("#{attr}")
              old = read_attribute("#{attr}")
              changed_attributes["#{attr}"] = val if old != val
            end

            write_attribute("#{attr}", val)
          end
        DEFINE
      end

      class_eval <<-DEFINE
        def save_with_dirty_tracking(*args)
          if status = save_without_dirty_tracking(*args)
            changed_attributes.clear
          end

          status
        end

        def reload_with_dirty_tracking(*args)
          record = reload_without_dirty_tracking(*args)
          changed_attributes.clear
          record
        end

        def changed_attributes
          @changed_attributes ||= {}
        end

        def attribute_changed?(attr)
          changed_attributes.include?(attr)
        end

        alias save_without_dirty_tracking save
        alias save save_with_dirty_tracking

        alias reload_without_dirty_tracking reload
        alias reload reload_with_dirty_tracking
      DEFINE
    end
  end
end
使い方
class Entry < ActiveRecord::Base
  include DirtyTracking
  dirty_tracking :title

  def validate_on_update
    errors.add(:title, 'は編集できません') if title_changed?
  end
end


こういう要求はあまりないのかもしれませんが、2.1 の機構の中身にも興味があったので実装してみました。