問題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テーブルの別々のカラム(email
と email2
)に格納すると、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)が特に効果を発揮するのは、このようなケースです。