Rails 4.0.2 です。
(2016/02/04 追記)
最近のバージョンだと、ここで紹介する方法は使えなくなったと情報をいただきました。
コメントで教えていただいた方法か、Concern を使うのが良さそうです。
発端
auto increment な id 以外に、ユーザが自分で設定できるユニークな文字列のカラムを用意したい、みたいな時に、用意されているValidatorを組み合わせて、以下のようにモデルに設定する。
class User < ActiveRecord::Base
validates :username,
presence: true, # 必須にしたい!
uniqueness: true, # URLに使うしユニーク!
length: { maximum: 16 }, # あんまり長いのも……
format: { with: /\A[a-z0-9]+\z/i } # やっぱり半角英数字のみだよね!
end
これが、モデル一個だけなら良いのだけれど、複数のモデルで似たようなことをやろうとすると、面倒だったりする。
class Item < ActiveRecord::Base
# なんだか User と似たようなこと書いてるなー……
validates :name,
presence: true,
uniqueness: { scope: :user_id },
length: { maximum: 32 },
format: { with: /\A[a-z0-9]+\z/i }
end
なんとか format
部分だけ Validator にしたとしても、その手間の割に楽になっている気がしない。
class NameFormatValidator < ActiveModel::EachValidator
def validate_each(record, attribute, value)
unless /\A[a-z0-9]+\z/i === value
record.errors[attribute] << (options[:message] || "invalid")
end
end
end
class User < ActiveRecord::Base
# ちっとも楽になった気がしない……
validates :username,
presence: true,
uniqueness: true,
length: { maximum: 16 },
name_format: true
end
理想はこういう形。
class User < ActiveRecord::Base
validates :username, name_format: { maximum: 16 }
end
class Item < ActiveRecord::Base
validates :name, name_format: { scope: :user_id, maximum: 32 }
end
なので、全部まとめて Validator にしようかと思ったけど、#validate_each
の中にこのチェック全部入れるの? と手が止まってしまう。
class NameFormatValidator < ActiveModel::EachValidator
def validate_each(record, attribute, value)
# 空だったらチェックして……?
if value.blank?
# あれ、空の時のエラーメッセージなんだっけ?
end
# ユニークかどうかって Validator の中からどうやるんだっけ?
# ...
end
end
そもそも、その辺りの仕組みはすでにあるのに、#validate_each
の中に再実装しないといけないっていうのが間違ってる!
調べた
じゃあ、そもそもすでにある Validator ってどうなってるの?
というわけで ActiveModel::Validations
の中身を確認してみた。
ActiveModel::Validations
は ActiveModel::Model
に include
されている。
参考 ⇒ http://apidock.com/rails/ActiveModel/Validations
ActiveModel::Validations::ClassMethods
の中に .validates
が定義されている。
def validates(*attributes)
defaults = attributes.extract_options!.dup
validations = defaults.slice!(*_validates_default_keys)
raise ArgumentError, "You need to supply at least one attribute" if attributes.empty?
raise ArgumentError, "You need to supply at least one validation" if validations.empty?
defaults[:attributes] = attributes
validations.each do |key, options|
next unless options
key = "#{key.to_s.camelize}Validator"
begin
validator = key.include?('::') ? key.constantize : const_get(key)
rescue NameError
raise ArgumentError, "Unknown validator: '#{key}'"
end
validates_with(validator, defaults.merge(_parse_validates_options(options)))
end
end
色々やっているけれども、対象のattribute名、Validatorの名前とそのoprions、に分解しValidatorクラスを見つけて .validates_with
に渡している。
def validates_with(*args, &block)
options = args.extract_options!
args.each do |klass|
validator = klass.new(options, &block)
validator.setup(self) if validator.respond_to?(:setup)
if validator.respond_to?(:attributes) && !validator.attributes.empty?
validator.attributes.each do |attribute|
_validators[attribute.to_sym] << validator
end
else
_validators[nil] << validator
end
validate(validator, options)
end
end
.validates_with
の中では Validator クラスのインスタンスを作っている。
そのインスタンス作成直後に validator.setup(self) if validator.respond_to?(:setup)
ということをしている。
どうやら Validator クラスに #setup
というメソッドがあれば、それを呼ぶらしい。
試しにいくつかの Validator クラスを見てみたけれども #setup
メソッドを持っているものはそんなにない。
ActiveModel::Validations::ConfirmationValidator
にあったので、どんな感じか見てみる。
def setup(klass)
klass.send(:attr_reader, *attributes.map do |attribute|
:"#{attribute}_confirmation" unless klass.method_defined?(:"#{attribute}_confirmation")
end.compact)
klass.send(:attr_writer, *attributes.map do |attribute|
:"#{attribute}_confirmation" unless klass.method_defined?(:"#{attribute}_confirmation=")
end.compact)
end
klass を受け取って、その klass に対してあらかじめ何か操作をしたい時に使えるらしい。
いくつかコードを確認して、 attributes
と options
が使えることがわかったので、以下のように書ける。
class NameFormatValidator < ActiveModel::EachValidator
def setup(klass)
klass.validates(
*attributes,
presence: true,
uniqueness: options[:scope].present? ? options.slice(:scope) : true,
length: options.slice(:in, :minimum, :maximum, :is),
)
end
def validate_each(record, attribute, value); end
end
これで
class Item < ActiveRecord::Base
validates :name, name_format: { scope: :user_id, maximum: 32 }
end
これが実現できる!
Concern でも良かったかも
と思ったけど Concern でも良かったかなーって、書きながら思い付いた。
module NameFormatColumn
extend ActiveSupport::Concern
module Class Methods
def name_format_column(*attributes)
options = attributes.extract_options!
send(
:validates,
*attributes,
presence: true,
uniqueness: options[:scope].present? ? options.slice(:scope) : true,
length: options.slice(:in, :minimum, :maximum, :is),
)
end
end
end
class Item < ActiveRecord::Base
include NameFormatColumn
name_format_column :name, scope: :user_id, maximum: 32
end
今思い付いて書き下したコードなので、これで動くかはわかんない。