🌝

【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)

リレーションはこんな感じです

book.rb
has_many :book_tags, dependent: :destroy
has_many :tags, through: :book_tags
tag.rb
has_many :book_tags, dependent: :destroy
has_many :books, through: :book_tags
book_tag.rb
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's Techblog

Discussion