【備忘録】Formオブジェクトについて
仕事で孫モデルまでを一回の処理で作成・更新する必要があり、
汚いコードでなんとか記述しました。
今後はなるべく簡潔に記述したいと思い、Formオブジェクトについてまとめることにしました。
Form Objectとは何か?
form_withを使用することで目的が達成されるユースケースを実装するために用いるものです。
何故使用するのか?
Railsではresourcesメソッドによるリソースベースのルーティングが基本になっています。
URLで表されるリソースをデータベースのテーブルと1対1に対応させており、これらのCRUD操作を通してユーザーとやり取りすることを想定しています。
しかしながら、次のようなケースではこの考え方だけでは上手くいかないケースに該当します。
-
CRUD操作が複数のユースケースで行われる場合
例えば、ユーザー登録を行う場合はフォームから登録する場合もあれば、CSVから登録される場合もあります。
前者の場合は利用規約に同意するチェックボックスの値を確認する必要がありますが、後者の場合は確認する必要がないような場合だとこれに対応する形でモデルの肥大化やコールバックの複雑化といった問題が発生します。 -
複数のモデルで登録・更新操作が必要になる場合
親モデルに操作が行われた場合に子モデルの操作が必要になる場合を考えます。
この場合accepts_nested_attributes_for
メソッドが候補になります。
しかしながらこのメソッドは非常に評判が悪く、使用を禁止している場合もあるとのことです。
- 対応するテーブルが存在しない場合
例えば、Cookieベースのセッションの作成・削除操作のようなユースケースでは対応するモデルが存在しません。他のモデルやコントローラーに処理を実装することもできますが、記述が肥大化してしまいます。他にもElasticSearchへのリクエストなどのケースが想定されます。
もう少し抽象的に表現すると次の記事のように表現されます。
ユーザの入力の整形や永続化をコントローラだけで行うと、コントローラが肥大化してしまいます。 この原因はコントローラがモデル層の知識をもちすぎるためにあります。 このときビューもフォームを表示するための知識をもつことになるため、コントローラと同じような問題が起こってしまいます。 このことは単一責任の原則に反し、モデル層の変更がコントローラやビューに影響を及ぼすことになります。
逆にActiveRecordモデルにこういった責務をもたせると、今度はActiveRecordモデルがフォームの知識を持ちすぎてしまいます。 フォームという独立した責務があるのであれば、これをひとつのクラスにカプセル化する、というのがFormオブジェクトの役割です。
使用例
孫要素までまとめて保存する例を考えます。
サービスが複数存在し、
ユーザーはサービスの権限(読み書き)を設定するルールを複数所有するケースを考えます。
テーブルとER図は次の通りです。
Table | Column | Type |
---|---|---|
rules | id | interger |
rule_name | varchar | |
authorities | id | interger |
rule_id | interger | |
authority | interger | |
authority_service_relations | id | interger |
authority_id | interger | |
service_id | interger | |
services | id | interger |
service_name | varchar |
この例はRuby 2.7.5,Ruby on Rails 6.1.6で動作を確認しています。
Formオブジェクト
class RuleForm
include ActiveModel::Model
include ActiveModel::Attributes
attribute :rule_name, :string
attribute :read
attribute :write
validates :rule_name, presence: true, length: { in: 1..30 }
delegate :persisted?, to: :rule
def initialize(attributes = nil, rule: Rule.new)
@rule = rule
attributes ||= default_attributes
super(attributes)
end
def save
return if invalid?
ActiveRecord::Base.transaction do
rule.authorities.destroy_all
if read[:services]&.any?
read_authority = rule.authorities.build(authority: Authority.authorities[:read] )
read[:services].compact_blank!
read[:services].each do |read_service|
read_authority.authority_service_relations.build(service_id: read_service)
end
end
if write[:services]&.any?
write_authority = rule.authorities.build(authority: Authority.authorities[:write])
write[:services].compact_blank!
write[:services].each do |write_service|
write_authority.authority_service_relations.build(service_id: write_service)
end
end
rule.update!(rule_name: rule_name)
end
rescue ActiveRecord::RecordInvalid
false
end
def to_model
rule
end
private
attr_reader :rule
def default_attributes
{
rule_name: rule.rule_name,
read: { services: rule.authorities.read.joins(:services) },
write: { services: rule.authorities.write.joins(:services) },
}
end
end
コードの説明です。
include Active::Model
はActiveModelが提供するモジュール郡の一部をまとめたモジュールです。
複数のモジュールを組み合わせて、コントローラやビューのメソッドとの連携に必要なインターフェースを提供します。
include Active::Attributes
は属性を定義するためのattributesメソッドを利用できるようになるモジュールです。attributesメソッドでは、第1引数で属性名を、第2引数で型名を指定します。
なお、今回のコードは前述した通りRails6系で確認しておりますが、
Rails7系ではActiveModel::Attributes
についても一緒にincludeされるようになりました。
delegated
は委譲に関するメソッドです。
ruleモデルからpersisted?
を委譲することでビューからform_withで送信する際に
自動的にフォームのアクションをPOST
とPATCH
に切り替えてくれます。
initialize
はFormオブジェクトの値を初期化しています。
こちらについては先ほども引用した次の記事が勉強になりました。
to_model
はビューの表示(form_with
)に必要なメソッドです。 アクションのURLを適切な場所(ここではrule_pathやrule_path(id))に切り替えてくれます。
コントローラ
class RulesController < ApplicationController
def index
@rules = Rule.all
end
def new
@form = RuleForm.new
end
def create
@form = RuleForm.new(rule_params)
if @form.save
redirect_to rules_path
else
render :new
end
end
def edit
load_rule
@form = RuleForm.new(rule: @rule)
end
def update
load_rule
@form = RuleForm.new(rule_params, rule: @rule)
if @form.save
redirect_to rules_path
else
render :edit
end
end
private
def rule_params
params.require(:rule).permit(:rule_name,
read: {
services: []
},
write: {
services: []
}
)
end
def load_rule
@rule = Rule.find(params[:id])
end
end
ビュー
<%= form_with model: @form, local: true do |form| %>
<%= form.text_field :rule_name %>
<%= form.fields_for :read do |read| %>
<%= read.collection_check_boxes :services, Service.all, :id, :service_name %>
<% end %>
<%= form.fields_for :write do |write| %>
<%= write.collection_check_boxes :services, Service.all, :id, :service_name %>
<% end %>
<%= form.submit %>
<% end %>
<%= form_with model: @form, local: true do |form| %>
<%= form.text_field :rule_name %>
<%= form.fields_for :read do |read| %>
<%= read.collection_check_boxes :services, Service.all, :id, :service_name, checked: @form.read[:services].pluck(:service_id) %>
<% end %>
<%= form.fields_for :write do |write| %>
<%= write.collection_check_boxes :services, Service.all, :id, :service_name, checked: @form.write[:services].pluck(:service_id) %>
<% end %>
<%= form.submit %>
<% end %>
モデル
class Rule < ApplicationRecord
has_many :authorities
validates :rule_name, presence: true, length: { in: 1..30 }
end
class Authority < ApplicationRecord
belongs_to :rule
has_many :authority_service_relations
has_many :services, through: :authority_service_relations
enum authority: { read: 1, write: 2 }
end
class Service < ApplicationRecord
has_many :authorities, through: :authority_service_relation
end
class AuthorityServiceRelation < ApplicationRecord
belongs_to :authority
belongs_to :service
end
感想
Formオブジェクトを作成するとフォームに関する記述が一箇所にまとまるので
非常に読みやすいです。
自分で作成した時は一番上のモデル(親モデル)に孫モデルの保存処理まで記述してしまい、
読みにくくなったので大変ありがたいです。
また、POST
とPATCH
の切り替えなどは普段はあまり意識しておりませんでしたが、
自力で実装する体験を通して理解を深めることができました。
一方でRailsに頼るあまり、純粋なRubyのクラスの扱い方に苦戦しました。
今後はそちらについても理解を深めていきたいと思います。
Discussion