はじめに
1対多で関連するデータを nested_form で登録する際、子レコード間の関連を検証したいときがある。
この記事ではそうしたユースケースに対応可能なバリデーションの実装例を紹介する。
サンプルアプリケーションの概要
1対多のデータを保管するフォームを作成する。
フォームの作成には simple_form と nested_form を利用している。
参考: nested_form について
1対多のフォームを作成するときに便利なgem。
子要素を動的に追加、削除ができるビューのヘルパーメソッドが利用が可能になる。
nested_form の詳細は下記ページを参照。
モデル
モデルのコードは以下の通り。(nested_form関連の細かい設定は省略している)
class Project < ActiveRecord::Base
has_many :tasks
end
class Task < ActiveRecord::Base
belongs_to :project
end
ProjectとTaskにはそれぞれ以下のような属性がある。
Project
- name (プロジェクト名)
- published (公開フラグ。公開 or 非公開)
Task
- name (タスク名)
- priority (優先度。最優先 or 高い or 低い)
フォーム
ブラウザで表示したフォームは以下の通り。
nested_form を使っているので、フォーム内でタスクの追加や削除が行える。
ビュー
ビューのコードは以下の通り。
simple_nested_form_for
や link_to_remove
、link_to_add
メソッド等が nested_form でのメソッド。
= simple_nested_form_for(@project) do |f|
- if @project.errors.any?
#error_explanation
%h2
= pluralize(@project.errors.count, "error")
prohibited this project from being saved:
%ul
- @project.errors.full_messages.each do |message|
%li= message
.field
= f.input :name
= f.input :published
.field
%h2 タスク
= f.fields_for :tasks do |tf|
= tf.input :name
= tf.input :priority, collection: Task.priority.options, include_blank: false
= tf.link_to_remove 'タスク削除'
%br
%br
.field
= f.link_to_add 'タスク追加', :tasks
.actions
= f.submit '登録'
バリデーション例
ここでは次のようなバリデーションを実装する。
-
Task
は1つ以上登録が必要 (子レコード全体でチェックするケース) - 最優先の
Task
は1つだけ (子レコード同士でチェックするケース) -
Project
が非公開の場合はTask
に 最優先は設定してはいけない (親と子レコード複合でチェックするケース)
今回、このバリデーションを次のように実装した。
class Project < ActiveRecord::Base
has_many :tasks, dependent: :destroy, inverse_of: :project
accepts_nested_attributes_for :tasks, allow_destroy: true
validates :name, presence: true
default_value_for :published, true
# 1. タスクが1つも登録されていない
validate :require_any_task
def require_any_task
errors.add(:base, :no_task) if tasks.blank?
end
# 2. 優先度が最優先は1つだけ
validate :only_one_top_priority
def only_one_top_priority
top_priority_count = tasks.select { |task| task.priority.top? }.count
errors.add(:base, :only_one_top_priority_for_project) if top_priority_count > 1
end
# 3. 非公開の場合はタスクに最優先は設定してはいけない
validate :not_available_top_priority_if_private
def not_available_top_priority_if_private
if published.blank? && tasks.any? { |task| task.priority.top? }
errors.add(:base, :not_available_top_priority_if_private)
end
end
end
子レコードのチェック時はまだデータの保管自体はされていない(配列にデータが入っているだけの状態)。
そのため、 where
や count
等のSQLを使う方法では判定できないことに注意する。
なお、バリデーション部分のコードはあくまでRailsの機能を使っているだけであり、 nested_form とは無関係である。
エラー時の表示例
バリデーションエラーが発生すると次のように表示される。
エラー表示を子レコードの指定フィールドにしたい場合
上の表示例だと、画面の上部にしかメッセージが表示されないため、フォームのどこでエラーが起きているのかわかりづらい。
そこで各フィールドにもエラーを表示できるようにした。
# 指定のフィールドにエラーを付与したい
validate :only_one_top_priority
def only_one_top_priority
top_priority_count = tasks.inject(0) do |count, task|
count += 1 if task.priority.top?
task.errors.add(:priority, :only_one_top_priority) if count > 1
count
end
# 親レコードにエラーを設定しないと保管されてしまうことに注意
errors.add(:base, :only_one_top_priority_for_project) if top_priority_count > 1
end
上のコードのポイントは次の通り。
- 子レコード毎にチェックしてエラーを設定する。
- このとき、親レコードにエラーを設定しないとバリデーションがパスして保存されてしまうので注意する。
エラー時の表示例
問題が発生したタスク(フィールド)にエラーが表示されている。
まとめ
今回作成したサンプルアプリケーションのポイントは以下の通り。
- 親モデル内で子モデルのデータを全てチェックできるので、柔軟にバリデーションが実装できる。
- 子レコードをチェックする際は
where
やcount
等のSQLを使う方法では判定できない。(レコードがまだ保存されていないため) - 子レコードだけがバリデーションエラーになった場合は、親レコードにもエラーを設定する必要がある。
また、今回のサンプルアプリケーションでは採用しなかったが、可読性や保守性の観点からバリデーション部分を Concern に切り分けるのも良い。