1. kano-e

    Posted

    kano-e
Changes in title
+複数のValidatorを組み合わせたバリデーションルールをcustomr validatorとして用意する
Changes in tags
Changes in body
Source | HTML | Preview
@@ -0,0 +1,209 @@
+
+Rails 4.0.2 です。
+
+auto increment な id 以外に、ユーザが自分で設定できるユニークな文字列のカラムを用意したい、みたいな時に、用意されているValidatorを組み合わせて、以下のようにモデルに設定する。
+
+```ruby:app/models/user.rb
+class User < ActiveRecord::Base
+ validates :username,
+ presence: true, # 必須にしたい!
+ uniqueness: true, # URLに使うしユニーク!
+ length: { maximum: 16 }, # あんまり長いのも……
+ # やっぱり半角英数字のみだよね!
+ format: { with: /\A[a-z0-9]+\z/i }
+end
+```
+
+これが、モデル一個だけなら良いのだけれど、複数のモデルで似たようなことをやろうとすると、面倒だったりする。
+
+```ruby:app/models/item.rb
+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 にしたとしても、その手間の割に楽になっている気がしない。
+
+```ruby:app/validators/name_format_validator.rb
+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
+```
+
+```ruby:app/model/user.rb
+class User < ActiveRecord::Base
+ # ちっとも楽になった気がしない……
+ validates :username,
+ presence: true,
+ uniqueness: true,
+ length: { maximum: 16 },
+ name_format: true
+end
+```
+
+
+理想はこういう形。
+
+```ruby:app/models/user.rb
+class User < ActiveRecord::Base
+ validates :username, name_format: { maximum: 16 }
+end
+```
+
+```ruby:app/models/item.rb
+class Item < ActiveRecord::Base
+ validates :name, name_format: { scope: :user_id, maximum: 32 }
+end
+```
+
+なので、全部まとめて Validator にしようかと思ったけど、`#validate_each` の中にこのチェック全部入れるの? と手が止まってしまう。
+
+```ruby:app/validators/name_format_validator.rb
+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` が定義されている。
+
+```ruby:activemodel/lib/active_model/validations/validates.rb
+ 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` に渡している。
+
+```ruby:activemodel/lib/active_model/validations/with.rb
+ 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` にあったので、どんな感じか見てみる。
+
+```ruby:activemodel/lib/active_model/validations/confirmation.rb
+ 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` が使えることがわかったので、以下のように書ける。
+
+```ruby:app/validators/name_format_validator.rb
+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
+```
+
+これで
+
+```ruby:app/models/item.rb
+class Item < ActiveRecord::Base
+ validates :name, name_format: { scope: :user_id, maximum: 32 }
+end
+```
+
+これが実現できる!
+と思ったけど Concern でも良かったかなーって、書きながら思い付いた。
+
+```ruby:app/models/concerns/name_format_column.rb
+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
+```
+
+今思い付いて書き下したコードなので、これで動くかはわかんない。