formオブジェクトを使った複数テーブルへの値の保存を1週間位試行錯誤してやっと実装できたので、記事に残しておこうと思います。
色々な方の実装方法を真似て作ったので解釈が間違っているところや、理解が不十分なところもありますが、自分はこう解釈してこの記述をしているということで書いていきます。
前提
プログラミング初学者の備忘録的な感じで書いており、間違いがある場合がありますので、ご注意ください。
解釈やそもそもの定義など間違っている部分等ありましたらご指摘していただけると幸いです。
ターミナルでのファイル生成の記述については全て省略しています。
###動作環境
macOS Catalina 10.15.7
Rails 6.0.3.4
Ruby 2.6.5p114
formオブジェクトって何?
デザインパターンの1つで、1つの投稿フォームから複数のモデルに関連するデータを更新できるものです。
簡単に言うと、1つの投稿フォームから複数のテーブルへの保存の処理をするまとめ役みたいな感じです。
そのため、formオブジェクトにはform_withメソッドに対応する機能と、バリデーションを行う機能をもたせることが必要になります。
form_withで複数のテーブルに保存するデータをformオブジェクトに送り、
届いたデータに対して、各モデルのバリデーションをformオブジェクトで行った後に複数のテーブルへデータを保存すると言った流れです。
formオブジェクトを使ったフォームの実装
今回は以下2つをポイントに実装を行いました。
formオブジェクトで記事の新規投稿、更新処理ができる。
1つの入力フォームから複数のタグの新規登録、更新処理ができる。
記事の新規投稿のみに比べ、更新処理も実装するとかなり手間がかかりました。
ER図と実際の投稿フォーム
ER図はこのような感じで、赤枠の部分をformオブジェクトで実装しました。
formオブジェクトを使い、1つの投稿フォームでarticleとtagを保存するような設計です。
具体的な動きとしては、下図のようなフォームで記事にタグを付けて投稿し、記事とタグをそれぞれのテーブルに保存します。
ArticleとTagを紐付けるために中間テーブルとして、article_tag_relationsテーブルを作っています。
モデルについて
各モデルは以下のように記述しました。
マイグレーションファイルもカラムの部分のみをモデルの下に書いています。
Articleモデル(app/models/article.rb)
class Article < ApplicationRecord
has_many :article_tag_relations, dependent: :destroy
has_many :tags, through: :article_tag_relations
end
マイグレーションファイル
t.string :title, null: false
t.text :output, null: false
t.string :action
t.integer :user_id
Tagモデル(app/models/tag.rb)
class Tag < ApplicationRecord
has_many :article_tag_relations, dependent: :destroy
has_many :articles, through: :article_tag_relations
validates :tag_name, uniqueness: true
end
マイグレーションファイル
t.string :tag_name, uniquness: true
ArticleTagRelationモデル(app/models/article_tag_relation.rb)
class ArticleTagRelation < ApplicationRecord
belongs_to :article
belongs_to :tag
end
マイグレーションファイル
t.references :article, foreign_key: true
t.references :tag, foreign_key: true
モデルについては2つのポイントがあり
dependent: :destroy
1つは、articleが削除されたとき、tagが削除されたときには中間テーブルのarticle_tag_relationから削除された値が含まれるレコードも削除されるようにしました。
validates :tag_name, uniquness: true
もう1つは、同じ名前のtagが複数保存されないようにしています。
コントローラーについて
コントローラーは以下のように記述しました。
class ArticlesController < ApplicationController
before_action :authenticate_user!, only: [:new, :edit, :update, :destroy]
before_action :set_article, only: [:show, :edit]
def index
@articles = Article.all.order('created_at DESC')
end
def new
@article_tag = ArticleTag.new
end
def create
@article_tag = ArticleTag.new(article_params)
tag_list = params[:article][:tag_name].split(',')
if @article_tag.valid?
@article_tag.save(tag_list)
redirect_to articles_path
else
render :new
end
end
def show
end
def edit
@article = Article.find(params[:id])
@article_tag = ArticleTag.new(article: @article)
end
def update
@article = Article.find(params[:id])
@article_tag = ArticleTag.new(article_params, article: @article)
tag_list = params[:article][:tag_name].split(',')
if @article_tag.valid?
@article_tag.save(tag_list)
redirect_to article_path(@article)
else
render :edit
end
end
def destroy
@article = Article.find(params[:id])
redirect_to root_path if @article.destroy
end
private
def article_params
params.require(:article).permit(:title, :output, :action, :user_id, :article_id, :tag_name, :tag_id).merge(user_id: current_user.id)
end
def set_article
@article = Article.find(params[:id])
end
end
コントローラーについては特に変わったところはなく、
tag_listがformオブジェクトで値を保存するために定義したsaveメソッドに使われるくらいです。
formオブジェクトの作成
formオブジェクトはapp/formsディレクトリを作成し、その直下にarticle_tag.rbというファイル名で作成しました。
class ArticleTag
include ActiveModel::Model
attr_accessor :title, :output, :action, :tag_name, :user_id, :tag_id, :article_id
with_options presence: true do
validates :title, length: { maximum: 40 }
validates :output, length: { maximum: 400 }
end
# レコードに値があるかないかでcreateかupdateかに分岐させる
delegate :persisted?, to: :article
def initialize(attributes = nil, article: Article.new)
@article = article
attributes ||= default_attributes
super(attributes)
end
def save(tag_list)
ActiveRecord::Base.transaction do
@article.update(title: title, output: output, action: action, user_id: user_id)
current_tags = @article.tags.pluck(:tag_name) unless @article.tags.nil?
old_tags = current_tags - tag_list
new_tags = tag_list - current_tags
old_tags.each do |old_name|
@article.tags.delete Tag.find_by(tag_name: old_name)
end
new_tags.each do |new_name|
article_tag = Tag.find_or_create_by(tag_name: new_name)
@article.tags << article_tag
article_tag_relation = ArticleTagRelation.where(article_id: @article.id, tag_id: article_tag.id).first_or_initialize
article_tag_relation.update(article_id: @article.id, tag_id: article_tag.id)
end
end
end
def to_model
article
end
private
attr_reader :article, :tag
def default_attributes
{
title: article.title,
output: article.output,
action: article.action,
tag_name: article.tags.pluck(:tag_name).join(',')
}
end
end
include ActiveModel::Model
ActiveModel::Modelというモジュールをincludeメソッドで与えます。
この記述によって ArticleTagクラスがモデルとしての機能を行えるようになります。
form_withへの対応やバリデーションを行うために必要な記述です。
delegateメソッド
指定したオブジェクトにメソッドの実行を委譲させるものです。
委譲:あるオブジェクトの操作を一部他のオブジェクトに代替させる手法
言葉が難しいです。。。
delegate :メソッド名, to: :委譲先のオブジェクト
ここではpersisted?メソッドをarticleというオブジェクトに委譲しています。
to_modelメソッドと合わせて、
レコードに値が存在しないときにcreateアクション
レコードに値が存在するときはupdateアクションを動かすために必要な記述になります。
to_model
モデルであるためには、to_modelを定義する必要があります。
コントローラーやview helperにモデルが渡ったときにto_modelを呼んでモデルを操作するためです。
理解が浅いためうまく説明できないのですが、delegateメソッドで値があるときと無いときに応じてPOSTやPATCHの処理を切り替え、
to_modelメソッドはアクションのURLを適切な場所に切り替えているということらしいです。
initializeメソッド
initializeメソッドはnewメソッドでインスタンスを生成する時に初期値で設定する値などを定義するメソッドです。
attributesは属性値の意味で、
attributes ||= default_attributes
super(attributes)
arrtibutesが存在すればその値を、nilであれば、default_attributesをattributesに代入するといった記述です。
superは、スーパークラスを呼び出す記述です。
initializeをここでは再定義(オーバーライド)していますが、オーバーライドする前のinitializeメソッドを引数をattributesとして呼び出しています。
単にnewメソッドでインスタンスを生成するわけではなく、
attributesが存在すればその値を使ってインスタンスを生成
存在しなければ、default_attributesを使ってインスタンスを生成するといった記述です。
createアクションとupdateアクションを値のあるなしで使い分けるための記述を1つにまとめるために、initializeの再定義を行っていると思います。
投稿のみであれば、ここの記述は必要ありません。
更新にも対応するためにレコードに保存された値を取得するために定義しています。
default_attributes
privateメソッド以下にあるdefault_attributesは投稿フォームに入力する値のdefault値を定義しました。
articleとtagを使ってdefalut値を設定するためにattr_readerでarticleとtagを読み込んでいます。
ここでは書き込む必要はなく、値として読み込むだけの処理のためattr_readerで十分になります。
saveメソッド
saveメソッドは新規投稿と更新の両方をこのメソッド1つで行うことができます。
initializeを再定義したので、newアクションでdefault_attributesの値が入ったレコードが生成されます。
フォームの入力値をarticle_paramsとして取り出して、以下のコードで入力した値に更新するという処理にして新規登録します。
@article.update(title: title, output: output, action: action, user_id: user_id)
新規登録の場合は、defalut_attributesとして全ての値がnilのレコードが生成され、フォームの入力値(article_params)で更新するといった流れです。
更新処理の場合は、保存されたレコードの値がdefault_attributesとしてあり、フォームの入力値(article_params)で更新すると言った流れです。
タグの部分の記述については後述します。
ActiveRecord::Base.transaction
トランザクションの処理を記述する時に使うものです。
トランザクションとは、分割できないワンセットの処理単位のことです。
この中に書かれた処理で途中で例外処理(エラー)があったときには途中までの処理や結果はやらなかったことにするというものです。
ここではActiveRecord::Base.transaction doからendまでの処理
つまり、articleとtagの新規登録、更新処理がトランザクションになっています。
articleとtagの新規登録や更新する時にどこかで処理が失敗した場合、途中までやっていた処理はすべてなかったことにするということです。
タグの扱いについて
タグについては複数のタグを登録、編集できる機能にしました。
tag_list(フォームに入力したタグ)
current_tags(現在保存されているタグ、更新の場合のみ使われる変数)
old_tags(現在保存されていて、そのまま残すタグ)
new_tags(新しく保存されるタグ)
の4つを配列で定義し、配列内の各要素を保存するという流れです。
tag_list = params[:article][:tag_name].split(',')
上記の記述では、フォームで送信されたparamsからタグの値を取り出します。
このときsplit(',')では入力した値を,で区切って要素に分解して配列にするといった処理が行われます。
例えばタグを入力するフォームに
朝,昼,夜
と入力した場合、
tag_list = ["朝","昼","夜"]
と入ることになります。
新規登録の場合
current_tags = @article.tags.pluck(:tag_name) unless @article.tags.nil?
unless @article.tag.nil?
となっており、タグが空でない場合にcurrent_tagsが定義されるため新規登録の場合は定義されません。
new_tags = tag_listとなり以下の処理に移ります。
new_tags.each do |new_name|
article_tag = Tag.find_or_create_by(tag_name: new_name)
@article.tags << article_tag
article_tag_relation = ArticleTagRelation.where(article_id: @article.id, tag_id: article_tag.id).first_or_initialize
article_tag_relation.update(article_id: @article.id, tag_id: article_tag.id)
end
2行目:find_or_create_byメソッドではTagモデルを通じてTagsテーブルから、tag_nameがnew_nameのものを探し、なければその値を保存します。
3行目:次の行で保存されたarticle_tagを@article.tags、つまり投稿した記事のタグの配列に格納します。
4行目:中間テーブルに値を保存する処理です
first_or_initializeメソッドは新規登録の場合はinitializeつまり新しくレコードが生成され、更新の場合はレコードは生成されません。
5行目:生成したレコード、元々あったレコードをupdateメソッドで更新する
と言った流れで新規登録されます。
更新の場合
更新の場合はtag_listとcurrent_tagsを使って、
編集された時に削除されたタグを中間テーブルから削除し、新しく追加されたタグをTagsテーブルと中間テーブルに保存する必要があります。
current_tags = @article.tags.pluck(:tag_name) unless @article.tags.nil?
old_tags = current_tags - tag_list
new_tags = tag_list - current_tags
例として、元々登録していたタグをtag1,tag2とします。
1行目:投稿した@articleに紐づくtag達を配列形式で取得します。
tagsテーブルからpluckメソッドでtag_nameというカラムを指定し、投稿した記事につけたタグのtag_nameの値を配列に格納します。
2行目:元々登録していたけれど、編集によって削除されたタグをold_tagと定義しています。
例えば、元々tag1,tag2があって編集画面でタグの欄をtag1だけにした場合はtag_listはタグのフォームに入力された値であるため、
old_tags = ["tag1", "tag2"] - ["tag1"] =["tag2"]となります。
3行目:元々登録されていなかった新規のタグをnew_tagsと定義しています。
例えば、元々tag1,tag2があって編集画面でtag1,tag2,tag3とした場合、
new_tags = ["tag1", "tag2", "tag3"] - ["tag1", "tag2"] = ["tag3"]となります。
old_tagsについての処理
old_tagsはフォームから削除され、投稿につけなくなったタグです。
投稿に紐づくタグとして以下の記述で削除する必要があります。
old_tags.each do |old_name|
@article.tags.delete Tag.find_by(tag_name: old_name)
end
new_tagsについての処理
new_tagsは新たに追加したタグなので、新規のタグの場合はTagsテーブルに保存する必要があります。
また、投稿に紐づくタグとして新たに中間テーブルに保存する必要があります。
処理の内容については新規登録の場合の説明と全く一緒です。
まとめと感想
簡単なまとめ
formオブジェクトは1つのフォームから複数のテーブルに値を保存するために使われるデザインパターンの1つ
複数のタグを保存するにはpluckメソッドやsplitメソッドをうまく使って配列に格納し、eachメソッドを使ってそれぞれのタグに保存処理を行う
感想
delegateとto_modelメソッドについてなんとなく意味は分かった気がするが、formオブジェクトに記述して細かい部分でどう動いているのか完全には理解できていないので、もう少し理解を深める。
to_modelメソッドはActiveModel::Conversationに含まれるメソッドということで、他にもよく使っているメソッドがあるため今後勉強していく。
実装内容をすべて書いたのでものすごく長くなりました。
解釈間違い等ありましたらコメントしていただけると幸いです。
記事にわかりやすくまとめる技術も学んでいかなければ。。。
参考記事
formオブジェクトについて
https://product-development.io/posts/rails-design-pattern-form-objects
https://tomo-bb-aki0117115.hatenablog.com/entry/2020/10/29/232822
タグ付け機能
https://qiita.com/E6YOteYPzmFGfOD/items/bfffe8c3b31555acd51d
トランザクションについて
https://wa3.i-3-i.info/word142.html