Railsでウィザード形式のフォームを実装する時はテーブルを分けよう

f:id:satoryu:20190811081742j:plain unsplash-logo Estée Janssens

ユーザーへの入力を便利にしようと、ウィザード形式を採用しようと思って、試しに雑に作ってみた時にハマってしまった。

実際のコードは晒せないので、サンプルとして以下のような状況を考える。

例えば、プロフィールサービスを作っていて、ユーザー登録後にユーザーのニックネームやアバター画像、TwitterFacebookなどSNSのアカウント情報を入力させたくて、ウィザード形式を採用しようとしている。とする。

ここで、プロフィール入力を2ステップで構成されるウィザード形式にしたい。

  1. ニックネームと誕生日を入力
  2. SNSのプロフィールのURL

また、各プロフィールは必須入力としたい。

当初はウィザード形式は考慮していない実装だったので、ユーザー登録後に入力する情報は、いずれもユーザーのプロフィールということでprofilesテーブルに登録する。 テーブルのスキーマは以下のようになる。

ActiveRecord::Schema.define(version: 2019_08_11_000716) do
  create_table "profiles", force: :cascade do |t|
    t.string "nickname"
    t.date "birthday"
    t.string "twitter_profile_url"
    t.string "facebook_profile_url"
    t.integer "user_id"
    t.datetime "created_at", null: false
    t.datetime "updated_at", null: false
    t.index ["user_id"], name: "index_profiles_on_user_id"
  end

  create_table "users", force: :cascade do |t|
    t.string "email"
    t.string "password_digest"
    t.datetime "created_at", null: false
    t.datetime "updated_at", null: false
    t.index ["email"], name: "index_users_on_email", unique: true
  end
end

良くない実装

必須入力の項目のチェックするために、Profileモデルにバリデーションを設定する。

class Profile < ApplicationRecord
  validates :nickname, presence: true
  validates :birthday, presence: true
  validates :twitter_profile_url, presence: true
  validates :facebook_profile_url, presence: true
end

これだけだと、ウィザードの最初のステップでニックネームと誕生日を入力し、その時点の情報をprofilesに保存する時にその後に入力するtwitter_profile_urlなどのバリデーションによって止められてしまう。 validatesメソッドのonオプションで、そのバリデーションを有効にするタイミングを制御することができるので、それを使うことで一応は解決できそうではある。

class Profile < ApplicationRecord
  validates :nickname, presence: true, on: [:step1, :step2]
  validates :birthday, presence: true, on: [:step1, :step2]
  validates :twitter_profile_url, presence: true, on: :step2
  validates :facebook_profile_url, presence: true, on: :step2
end

このようにしておけば、モデルを保存する時に、save(context: :step1) などとすればステップごとに有効にしたいバリデーションを指定できる。

問題点

on オプションでバリデーションを追加すると、指定されたコンテキストでしかバリデーションが実行されないので、通常のsaveやupdate時にも有効にさせたい場合との区別が難しい。 このウィザード以外にProfileを保存や更新する場合に、必須入力であるということがチェックされない。チェックするにはコンテキストを指定する必要がある。

良さそうな実装

profilesテーブルを分割する。 そもそもウィザード形式で分ける時に、各ステップでの入力項目には意味がある単位にわかれているはず。そうでなければ、何の入力の助けにもならない。

ということで、ニックネームと誕生日、TwitterFacebookのURLとそれぞれの組でテーブルを分ける。

ActiveRecord::Schema.define(version: 2019_08_11_134450) do
  create_table "profiles", force: :cascade do |t|
    t.string "nickname"
    t.date "birthday"
    t.integer "user_id"
    t.datetime "created_at", null: false
    t.datetime "updated_at", null: false
    t.index ["user_id"], name: "index_profiles_on_user_id"
  end

  create_table "social_profiles", force: :cascade do |t|
    t.string "twitter_profile_url"
    t.string "facebook_profile_url"
    t.integer "user_id"
    t.datetime "created_at", null: false
    t.datetime "updated_at", null: false
    t.index ["user_id"], name: "index_social_profiles_on_user_id"
  end

  create_table "users", force: :cascade do |t|
    t.string "email"
    t.string "password_digest"
    t.datetime "created_at", null: false
    t.datetime "updated_at", null: false
    t.index ["email"], name: "index_users_on_email", unique: true
  end
end

当然だが、こうするとバリデーションは普通の記述になる。

class Profile < ApplicationRecord
  belongs_to :user

  validates :nickname, presence: true
  validates :birthday, presence: true
end

class SocialProfile < ApplicationRecord
  belongs_to :user

  validates :twitter_profile_url, presence: true
  validates :facebook_profile_url, presence: true
end

おわりに

ウィザード形式を採用する場合に、テーブルを意味のある単位に分割することでバリデーションが複雑にならずに済む。 データの設計がコードに影響することがわかる例だと思う。

分割することで悪い影響はまったく無いのだろうか。 1つあるとすれば、分割されたテーブルの情報を使ってユーザーを検索する場合にJOINするコストが発生することくらいではないだろうか。 例えば、上の例で、social_profilesテーブルのtwitter_profile_urlprofilesテーブルのnameusersテーブルのSELECT文のWHERE句で使う場合だ。 2つのテーブルくらいなら大丈夫そうだが、増えていった場合はパフォーマンスの影響も考慮した分割の方法を検討すると良いのかもしれない。

また、既に稼働しているサービスの場合、分割に際してデータ移行が必要となる。

参考

2年前くらいに読んだ「システム設計の原則」の中で似たようなことをやっていたのを思い出したのがきっかけ。 あらためてその部分を読もうと思ったのだけど、失くしてしまったのか、後輩にあげてしまったようなので、また買うかな…