問題02の解答と解説

2016/01/30

解答

先月(2015年12月)の演習問題各顧客に 2 個のメールアドレスを登録するの出題者(黒田)による解答例です。

11 回に分けて GitHub のリポジトリにコミットしてありますので、ソースコードの差分をご覧ください。

  • d48a83a Emailモデルを作成
  • 4f8ff61 Customerモデルからemail関連のカラムを削除
  • 2a80555 Customerからemailsをautosave
  • 403146f 顧客フォームのemails複数化
  • 6b69abb 顧客のメールアドレス重複チェック
  • 6e8e8dc 顧客のメールアドレス交換
  • 36805ab エラーメッセージの表示
  • 858ba18 メールアドレスの大文字・小文字に留意
  • 19c3bf0 メールアドレスの追加
  • c2bd03b メールアドレスの全角・半角に留意
  • 5484fa1 Emailモデルの振る舞いを変更

このページは「Rails 演習問題」という連載の一部です。詳しくは、はじめにをお読みください。

解説

今回は当初設定した締め切りまでに1件も解答の応募がなかったので、1週間締め切りを延ばしたところ、rinpopoyo さんから連絡がありました。途中で断念されたということですが、作りかけのコードを送っていただきました。

ソースコードの差分は 0690435 です。

ちょっと難しかったですかね。

rinpopoyo さんは、まず customers テーブルに email2 カラムを追加するところから開発を始めました。

class AlterCustomers3 < ActiveRecord::Migration
  def change
    add_column :customers, :email2, :string, null: false
  end
end

顧客ひとりに対して 1 個のメールアドレスしか登録できないという現行仕様から、0 個以上 2 個以下のメールアドレスを登録できるようにしてください、という問題でしたので、自然な発想です。

しかし、この方針のまま進むと、メールアドレスの重複を防ぐためのバリデーションの実装が難しくなります。

なぜでしょうか。

いま、顧客Aと顧客Bにそれぞれ2つのメールアドレスを設定すると仮定します。顧客Aのメールアドレスをma0、ma1、顧客Bのメールアドレスをmb0、mb1とします。このとき、ma0は、ma1、mb0、mb1のいずれとも異なっていなければなりません。

ma0とma1をcustomersテーブルの別々のカラム(emailemail2)に格納すると、Rails標準のuniquenessバリデーションが使えなくなります。

また、データベース管理システムが持つ UNIQUE 制約の仕組みも利用できません。

このため、私は次のようなマイグレーションスクリプトを作って新しく emails テーブルを定義しました。

class CreateEmails < ActiveRecord::Migration
  def change
    create_table :emails do |t|
      t.references :customer, null: false
      t.string :address, null: false            # メールアドレス
      t.string :address_for_index, null: false  # 索引用メールアドレス

      t.timestamps null: false
    end

    add_index :emails, :address_for_index, unique: true
    add_index :emails, :customer_id
    add_foreign_key :emails, :customers
  end
end

そして、もうひとつ customers テーブルから email カラムと email_for_index カラムを除去するマイグレーションを作成しました。

class AlterCustomers3 < ActiveRecord::Migration
  def change
    remove_column :customers, :email
    remove_column :customers, :email_for_index
  end
end

さて、今回はコミットが11回も行われていることから想像されるように、開発過程はやや複雑なものとなりました。

時間的制約から全体を詳細に解説することができないので、特徴的な工夫を2点紹介します。

まず、Email モデルのソースコード。

class Email < ActiveRecord::Base
  include StringNormalizer

  belongs_to :customer

  attr_writer :exchanging

  before_validation do
    if address
      self.address_for_index = if @exchanging
        address.downcase.gsub(/@/, '%')
      else
        address.downcase
      end
    end
  end

  validates :address, presence: true, email: { allow_blank: true }
  validates :address_for_index, uniqueness: { allow_blank: true }

  attr_writer :duplicated
  validate do
    errors.add(:address, :duplicated) if @duplicated
  end

  after_validation do
    if errors.include?(:address_for_index)
      errors.add(:address, :taken)
      errors.delete(:address_for_index)
    end
  end

  def address=(address)
    self[:address] = normalize_as_email(address)
  end

  def resave!
    if @exchanging
      @exchanging = false
      save!
    end
  end
end

8-16行目の before_validation ブロックをご覧ください。インスタンス変数 @exchanging の値が真であれば、メールアドレスに含まれる @% で置き換えています。

これは、ある顧客のメールアドレス2個を交換するときに UNIQUE 制約違反を回避するための工夫です。

顧客Aの第1のメールアドレスが foo@example.com で、第2のメールアドレスが bar@example.com であるとします。この状態で、第1のメールアドレスに bar@example.com をセットし、第2のメールアドレスに foo@example.com をセットして、顧客Aの情報を保存しようとすると、第1のメールアドレスを更新するときに address_for_index カラムに設定された UNIQUE 制約のためにエラーになります。

しかし、いったん bar%example.com で更新し、あとで改めて resave! メソッド(37-42行目)を呼び出すことにより、bar@example.com に書き換えれば、この問題は発生しません。

もうひとつの特筆すべき工夫は Customer モデルの before_validation ブロックです。

  before_validation do
    emails.each_with_index do |e0, i|
      emails.each_with_index do |e1, j|
        next if i == j || e0.address.blank?
        if e0.address.downcase == e1.address.try(:downcase)
          emails[i].duplicated = true
        end
        if e0.address.downcase == e1.address_was.try(:downcase)
          emails[i].exchanging = true
        end
      end
    end
  end

emails メソッドが返す配列の各要素は Email オブジェクトであり、HTML フォームから送られてきたデータにより書き換えられています。

配列に対して二重のループ処理を行って、同一顧客の2つのメールアドレスを比較しています。

1つ目の条件 e0.address.downcase == e1.address.try(:downcase) は、書き換え後のメールアドレスが重複していることを表現しています。この場合は、バリデーションエラーです。

2つ目の条件 e0.address.downcase == e1.address_was.try(:downcase) は、ある書き換え後のメールアドレスが書き換え前の(すなわちデータベースに格納されている)メールアドレスと重複していることを表現しています。この場合はバリデーションを通りますが、先に説明した UNIQUE 制約を回避する必要があるため exchanging 属性を「真」にセットします。


最後に一言。解説は書きませんでしたが、私の解答例で多分いちばん重要なのは RSpec によるテストコードです。この種の複雑なロジックを含むソフトウェアの開発を、テストコードなしで行うのは至難の業です。テスト駆動開発(TDD)が特に効果を発揮するのは、このようなケースです。