RailsのGem devise のvalidatableのソースコードを読んでみた

こんにちは!kossyです。



さて、今回はRailsプロジェクトにおいて認証機能を作成する時に用いられるGemであるdeviseの、
validatableのソースコードを読んでみたので、ブログに残してみたいと思います。




環境
Ruby 2.6.3
Rails 6.0.3
devise



まずはドキュメントを読む

上記ページのドキュメントを読んでみました。
Google翻訳先生に頼りつつ、一部意訳します。

Validatable creates all needed validations for a user email and password.
It's optional, given you may want to create the validations by yourself.
Automatically validate if the email is present, unique and its format is valid.
Also tests presence of password, confirmation and length.

Validatableは、ユーザーの電子メールとパスワードに必要なすべての検証を行います。
これはオプショナルです、なので自分で検証を行いたい場合は、自分で実装してください。

電子メールが存在し、一意であり、その形式が有効であるかどうかを自動的に検証します。
また、パスワードの存在、確認、および長さをテストします。

出典: https://rubydoc.info/github/plataformatec/devise/master/Devise/Models/Validatable

uniqueチェックや、presenceチェック、regexpチェックも行ってくれる優れものですね。

では中ではどのようにしてチェックを行っているのでしょうか。次の章でコードを読んでみるとします。


ソースコードを追う(その1)

以下のコードをご覧ください。

VALIDATIONS = [:validates_presence_of, :validates_uniqueness_of, :validates_format_of,
                     :validates_confirmation_of, :validates_length_of].freeze

validationを行う要素について、定数化を行っている箇所ですね。
先ほどのドキュメント翻訳にも記載されている通り、
存在確認・一意性確認・フォーマット確認・パスワード存在確認・長さ確認
を行うようです。

次は以下のメソッドをみてみます。

      def self.included(base)
        base.extend ClassMethods
        assert_validations_api!(base)

        base.class_eval do
          validates_presence_of   :email, if: :email_required?
          if Devise.activerecord51?
            validates_uniqueness_of :email, allow_blank: true, case_sensitive: true, if: :will_save_change_to_email?
            validates_format_of     :email, with: email_regexp, allow_blank: true, if: :will_save_change_to_email?
          else
            validates_uniqueness_of :email, allow_blank: true, if: :email_changed?
            validates_format_of     :email, with: email_regexp, allow_blank: true, if: :email_changed?
          end

          validates_presence_of     :password, if: :password_required?
          validates_confirmation_of :password, if: :password_required?
          validates_length_of       :password, within: password_length, allow_blank: true
        end
      end

ClassMethodsをextendしていますが、中身をみてみましょう。

      module ClassMethods
        Devise::Models.config(self, :email_regexp, :password_length)
      end

こちらですね。

Railsでdeivseを使う時に、config/initializer配下にdeviseのinitファイルが追加されますが、
その中に、email_regexpとpassword_lengthに関する記述があります。

  # ==> Configuration for :validatable
  # Range for password length.
  config.password_length = 6..128

  # Email regex used to validate email formats. It simply asserts that
  # one (and only one) @ exists in the given string. This is mainly
  # to give user feedback and not to assert the e-mail validity.
  config.email_regexp = /\A[^@\s]+@[^@\s]+\z/

このconfigで設定した値が、実際にバリデーションに使われる値になります。

デフォルトではパスワードの文字数は6文字から128文字の間で、
メールアドレスは簡易な正規表現でのチェックになっていますね。

この正規表現だと、通常メールアドレスでは使われない { や [ も使えてしまいますし、
Rails公式のガイドに記載された、

/\A([^@\s]+)@((?:[-a-z0-9]+\.)+[a-z]{2,})\z/i

https://guides.rubyonrails.org/active_record_validations.html#common-validation-options

を使うのがいいのかなと思いますが、、、

簡易な正規表現に落ち着いた経緯については、以下のブログが詳しかったです。



少し話が逸れましたね、、、
コードに話を戻します。

もう一度、 def self.included メソッドをみてみましょう。

      def self.included(base)
        base.extend ClassMethods
        assert_validations_api!(base)

        base.class_eval do
          validates_presence_of   :email, if: :email_required?
          if Devise.activerecord51?
            validates_uniqueness_of :email, allow_blank: true, case_sensitive: true, if: :will_save_change_to_email?
            validates_format_of     :email, with: email_regexp, allow_blank: true, if: :will_save_change_to_email?
          else
            validates_uniqueness_of :email, allow_blank: true, if: :email_changed?
            validates_format_of     :email, with: email_regexp, allow_blank: true, if: :email_changed?
          end

          validates_presence_of     :password, if: :password_required?
          validates_confirmation_of :password, if: :password_required?
          validates_length_of       :password, within: password_length, allow_blank: true
        end
      end

class_evalメソッドを使って、引数のbaseに動的にvalidationを定義しています。

ActiveRecord::Validations::PresenceValidatorに定義されている、validates_presence_of を emailに対して適用しています。

email_required?は、

      def email_required?
        true
      end

有無を言わさず true を返すようになっていました。笑

if Devise.activerecord51? はどんな処理でしょうか。

  def self.activerecord51? # :nodoc:
    defined?(ActiveRecord) && ActiveRecord.gem_version >= Gem::Version.new("5.1.x")
  end

みた感じ、Gemのバージョンが5.1.x より大きい時とそうでない時を判断するためのメソッドみたいですね。
5.1系から、attr_changed?等のメソッドが非推奨になったのに合わせて、Gemのバージョンで分岐させるようにしたみたいですね。

Gemの中身を見ていると、こういう泥臭い実装が出てきて面白いですね。

続きのvalidationオプションの使い方は、

を見てみてください。




ソースコードを追う(その2)

assert_validations_api!(base) メソッドをみるのを忘れてました。

      def self.assert_validations_api!(base) #:nodoc:
        unavailable_validations = VALIDATIONS.select { |v| !base.respond_to?(v) }

        unless unavailable_validations.empty?
          raise "Could not use :validatable module since #{base} does not respond " <<
                "to the following methods: #{unavailable_validations.to_sentence}."
        end
      end

先ほど記載したvalidations定数に対して、selectメソッドを用いて、「baseにvalidationsのメソッドが定義されていないもの」を集めて返した後、
それが一つでもあった場合、validatableモジュールを使えないように、例外を投げています。

password_required?はどんな処理でしょうか。

      # Checks whether a password is needed or not. For validations only. パスワードが必要かどうかを確認します。検証のみ。
      # Passwords are always required if it's a new record, or if the password 
      # or confirmation are being set somewhere.
      # 新しいレコードの場合は、常にパスワードが必要です。
      # または、パスワードまたはパスワード確認がどこかに設定されている場合。

      def password_required?
        !persisted? || !password.nil? || !password_confirmation.nil?
      end

新しいレコードの場合かパスワード(確認用も)が入力されていない場合にtrueが返るメソッドですね。


これで一通り処理を追えたかと思います。


まとめ

思ったよりも難しいことはしていない印象です。

正規表現がゆるゆるなのは驚きました。
悪戯で変なメールアドレスが登録されるのも不具合の元になりますので、
deviseを導入する際には、デフォルトの正規表現を変更するのは必須かもしれませんね。

勉強になりました。