【Ruby on Rails】Active Recordのupdateメソッドで中間レコードを良い感じに作ってくれるようなので詳しく見てみる
🎄Merry Christmas🎄 WWWAVE アドベントカレンダー 12/12の記事です
はじめに
※テーブル名とかAIの出力とかは例です
tagsのidが入った配列tag_idsに同期させるようにbooksとtagsの中間テーブルであるbook_tagsのレコードを作成/削除する処理を書いていたとき、AIに頼んだら以下のようなコードを出してきました
book = Book.find_by(id: 1)
tag_ids = [1,2,3]
book.update(tag_ids: tag_ids)
リレーションはこんな感じです
has_many :book_tags, dependent: :destroy
has_many :tags, through: :book_tags
has_many :book_tags, dependent: :destroy
has_many :books, through: :book_tags
belongs_to :book
belongs_to :tag
やけにシンプルなのでこれでいけるんかな...?と思いましたが試してみると実際に既存のbook_tagsのレコードは消えbook_idが1でtag_idが1~3の三つのbook_tagsレコードが作られていました。
ちょっと気になったので内部でどう動いているかみてみようと思います。
間違ってるところがあるかもしれませんが、ご了承ください🙇
updateメソッドを見てみる
RailsのgithubリポジトリからActive Recordのupdateメソッドを定義しているところを見てみます。
def update(attributes)
# The following transaction covers any possible database side-effects of the
# attributes assignment. For example, setting the IDs of a child collection.
with_transaction_returning_status do
assign_attributes(attributes)
save
end
end
本筋と関係ないところは無視するとして、assign_attributes(attributes)メソッドで作ったデータをsaveしているっぽいことが書いてます。
assign_attributesは何をしてるのでしょうか
assign_attributesが定義されているところを見てみましょう。
長くなるのでコードを全部は貼りませんが、キーと値のペアが送られてきたとして、最終的には送られてきたキー + '='のメソッドに送られてきた値を渡した返り値がそのまま戻ってくるみたいです
def _assign_attribute(k, v)
setter = :"#{k}="
public_send(setter, v)
rescue NoMethodError
if respond_to?(setter)
raise
else
attribute_writer_missing(k.to_s, v)
end
end
例えば今回の例のbook.update(tag_ids: tag_ids)だと、
book.tag_ids = tag_ids
が実行されて戻ります
updateでやっているのは単にこれを保存しているだけなので、book.tag_ids=の動作を見てみます。
長くなってきたので一旦セクション分けます
collection_singular_idsってのがあるらしい
Active Recordモデルの関連付けがロードされる時に実行されるコードを見てみます
def self.define_writers(mixin, name)
super
mixin.class_eval <<-CODE, __FILE__, __LINE__ + 1
def #{name.to_s.singularize}_ids=(ids)
association = association(:#{name})
deprecated_associations_api_guard(association, __method__)
association.ids_writer(ids)
end
CODE
end
どうやらここでtag_ids=のメソッドが定義されているらしいです。これ、Railsガイドにcollection_singular_idsって名前で書いてました。知らなかった
こいつは最終的に受け取ったidsを使ってレコードを作るみたいです、ids_writerも見てみましょう
def ids_writer(ids)
primary_key = reflection.association_primary_key
pk_type = klass.type_for_attribute(primary_key)
ids = Array(ids).compact_blank
ids.map! { |id| pk_type.cast(id) }
records = if klass.composite_primary_key?
klass.where(primary_key => ids).index_by do |record|
primary_key.map { |primary_key| record._read_attribute(primary_key) }
end
else
klass.where(primary_key => ids).index_by do |record|
record._read_attribute(primary_key)
end
end.values_at(*ids).compact
if records.size != ids.size
found_ids = records.map { |record| record._read_attribute(primary_key) }
not_found_ids = ids - found_ids
klass.all.raise_record_not_found_exception!(ids, records.size, ids.size, primary_key, not_found_ids)
else
replace(records)
end
end
ちょっとめんどくさくなってきたので詳細は省くのですが、ここではもらったidsを元にリレーション先のレコードがあるかどうかを確認し、一致しない場合はエラーを出します
ここでちょっとややこしいところがあります。
今回の例ではBookとTagはhas_many :throughのリレーションがあるため、こういった場合に存在しないtag_idを指定することはないため問題なく進みますが、単なるhas_manyの場合送られてきたidsのレコードを新たに作ることはせず、単に既存のレコードの関連先を変えるだけです
collection_singular_ids=がそもそも関連性をupdateするためのメソッドのためこうなっているみたいです、そう言われたら納得
この後、レコードの存在確認後に実行されるreplaceメソッドでリレーションを見て、中間テーブルのレコードを上書きするか関連性を更新するかが決まるようです
ちょっとそこまでみようと思ったのですが疲れたのでやめます.........余裕できたら追記するかもしれません
まとめ
- Active Recordのupdateは送られてきた
"#{key}="のメソッドにvalueを渡して処理した返り値を保存している - collection_singular_ids=というメソッドによって、関連性をまとめて更新できる
- 更新しているのは関連性のみであり、中間テーブルがない場合新しいレコードは作られない
余談
今回この記事を書くために色々Geminiに聞いたりしたのですが、思った以上に正確な情報を出してきて頼もしかったです。ちょっと前だったら嘘ばっかついてたと思う
今後こうやってフレームワークの中の動作を知っていく必要は(すでにないけど)なくなっていくと思いますが、AIも適度に使いつつこのように知識を広げていけるといいのかなぁと思います
以上です🌞
株式会社ウェイブのエンジニアによるテックブログです。 弊社では、電子コミック、アニメ配信などのエンタメコンテンツを自社開発で運営しております! wwwave.jp/service/
Discussion