LoginSignup
0
0

More than 1 year has passed since last update.

ActiveRecordの#updateについて 

Last updated at Posted at 2021-10-23

前提

Rails 6.1.4
ruby 2.7.1

はじめに

ActiveRecordのupdateメソッドを使う際、引数に一部の変更したいカラムだけ指定する時があるが、この時指定されていないattirubtenilとなりバリデーションに引っかかる場合があると思うがどうやって回避されているのだろうか?という疑問を持ったので挙動について詳しく調べていきたいと思う。

今回以下のソースコードを参考にした。
このmoduleには、普段よく使う様々なメソッドについてのコメントやコードが載っており眺めているだけでもかなり面白かった。
Github

save

まず始めにupdateメソッドの基礎となるsaveメソッドに紐解いてみる。
Github

概要

ソースコードを眺める前にコメントを翻訳し、動作の概要を把握する。

モデルを保存する。

新規レコードならDBに保存され、既存レコードなら更新する。

デフォルトではバリデーションを走らせる。そのうちのどれかが失敗したら動作はキャンセルされfalseを返し、レコードは保存されない。
しかし、+validate: false+オプションを渡すことで、バリデーションを回避できる。

デフォルトでは、+updated_at+属性に現在の時間を設定する。
しかし、+touch: false+を渡せば、タイムスタンプは更新されない。

+before_*+ callbackが+:abort+を投げたら、動作はキャンセルされfalseを返す。

saveメソッドは新規レコードを保存するだけでなく、既存レコードに対しては更新する動作も含むという点は重要そう!!
タイムスタンプに対するオプションがあったのは初めて知った。

ソースコード

次に実際のソースコードをみていく。
saveメソッドcreate_or_updateをよび例外をキャッチするだけの処理をしている。

create_or_updateについてみていこう。

def create_or_update(**, &block)
  _raise_readonly_record_error if readonly?
  return false if destroyed?
  result = new_record? ? _create_record(&block) : _update_record(&block)
  result != false
end

creat_or_update

始めにreadonlyがtrueの場合例外を発生させている。
readonly?

ちなみにこのreadonly?はレコード全体がreadonlyかどうかを判定しているが、クラスメソッドに各属性に対してreadonlyを付与できるメソッドもある。
attr_readonly

次に、destroyed?がtrueの場合falseを返している。
これはrailsのメモリ上に存在するインスタンスが以前DBからdeleteされていたらupdateする対象がないということなのでfalseを返しているのだと思う。

次に、new_record?で新規レコードか既存レコードかを確認し、それに応じて_create_record_update_recordを実行させている。

_create_record

    def _create_record(attribute_names = self.attribute_names)
      attribute_names = attributes_for_create(attribute_names)

      new_id = self.class._insert_record(
        attributes_with_values(attribute_names)
      )

      self.id ||= new_id if @primary_key

      @new_record = false

      yield(self) if block_given?

      id
    end

attribute_namesメソッドで属性名を文字列の配列として引数attribute_namesに設定している。
*属性名は DBのカラムに由来するのか、attr_accessorなどを追加すればそれも含まれて返されるのかは調査が必要。

attribute_names

attributes_for_createは、先ほどのattribute_namesを引数に渡して実行している。これは、attribute_namesの中から、次の条件「idがnilかつprimary key」を満たすattributeを削除したattirubte_namesを返す仕様になっている。

attributes_for_create

attributes_with_valuesにより、{"title": "test"}のような属性とその値をハッシュに変換したものをself.class._insert_recordに渡している。

attributes_with_values

_insert_recordでは、最初にattribute_namesidがない場合にidをセットしている。
その後の処理についてはちょっと追うのが辛くなってきたので関数名からして多分DBにインサートする処理を行なっているのだろう(白目)
返り値はおそらく新しく生成されたidであり、これを自身のインスタンス変数に格納していると思われる。
最後にnew_recordインスタンス変数をfalseにしてidを返り値としている。

_update_record

    def _update_record(attribute_names = self.attribute_names)
      attribute_names = attributes_for_update(attribute_names)

      if attribute_names.empty?
        affected_rows = 0
        @_trigger_update_callback = true
      else
        affected_rows = _update_row(attribute_names)
        @_trigger_update_callback = affected_rows == 1
      end

      yield(self) if block_given?

      affected_rows
    end

始めに、attributes_for_updateで、readonlyが付与されていない属性の配列を取得している。

attributes_for_update

それらの属性名からattributes_with_valuesで属性名をキーに、値をバリューにしたハッシュを取得し、_update_recordに渡している。
先ほどと同様に一旦_update_recordについて読み解くのはスルーして関数名からおそらくDBに対して更新処理を行なっているという予測に留める。
最後に返り値としてaffected_rowを返しているが、これは変数名から変更されたカラム数だと思う。。


create_or_update.rb
result = new_record? ? _create_record(&block) : _update_record(&block)
result != false

ここで、やっとcreate_or_updateに戻ってきて、_create_record_update_recordの返り値とfase!=で比較した結果を返り値としている。

これがsaveに返り値として渡されこの値がsaveの返り値となる!

ここまでがsaveメソッドの流れである。では次にようやくupdateについて読み解いていく。

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

update

with_transaction_returning_statusにブロックを渡している。このメソッドはソースコードを読み解くのが難しかったのでコメントで大まかな動作を把握するだけに留める。

transaction内で渡されたブロックを実行し、返り値をステータスフラグをしてキャプチャする。
trueの場合transactionはコミットされ、そうでなければROLLBACKされる。
いかなる場合でもステータスフラグを返す。

ほぼtransactionのような挙動をしてくれるという解釈で進める。
次にassign_attributes(attributes)により属性の値をインスタンス変数に設定する。
ちなみに、assign_attributesはActiveModelモジュールのものを使っている。と思う。
Github

最後にsaveを実行している。
結局updateメソッドはsaveを実行していたのだった〜

まとめ

  • updateのほとんどはsaveメソッドの処理
  • saveは引数でattributesを指定できないが、updateではできる。

updateに渡されなかったattributeについて

post = Post.find(1)
のように既存のレコードをメモリ上に取得する時にインスタンス変数として全てのカラムの値を取得している。よって、
post.update({title: 'hode'})
のようにtitle以外の属性を渡さなかったといってその属性がnilの状態でupdateが走るわけではなくレコードの値を保持した状態でupdateが走る。故に、バリデーションエラーが起こらないのだと思う。

ここについては、いつバリデーションが走るのか、どのようにバリデーションが行われているのかをコードから読み解くことはできなかったので憶測になってしまう。。

ちなみにupdate実行時のSQLを見てみると引数に渡した属性(と
update_at)のみUPDATEされている。

post.update(title: 'changed')

# UPDATE "posts" SET "title" = $1, "updated_at" = $2 WHERE "posts"."id" = $3  [["title", "changed"], ["updated_at", "2021-10-23 06:38:35.361966"], ["id", 1]]

saveについても検証してみると

post.title = 'changed'
post.save

# UPDATE "posts" SET "title" = $1, "updated_at" = $2 WHERE "posts"."id" = $3  [["title", "fix"], ["updated_at", "2021-10-23 06:41:36.512688"], ["id", 1]]

のように変更された属性に対してのみUPDATE SQLが実行されている。
どの属性の値も変えてない場合は、SQLが発行されなかった。

また、属性の値を同じ値で更新しようとすると、、

p.title
# => 'changed'
p.update(title: 'changed')
# => true
p.title
# => 'changed'
p.title = 'changed'
p.save
# => true

なんと、SQLが発行されなかった!
しかもこの挙動はrelationshipが作成されていて関連先のオブジェクトを代入する時にも使える!!

post.comment_ids
# => [1,2,3]

post.comment_ids = [1,2,3]
# => 更新処理はされない comment.idが1,2,3のレコードを取得するのみ。

post.comment_ids = [1,2]
# => SQLは省略するが、comment.id = 3のレコードに対してcomment.post_idをnilにする更新処理のみ。

ここら辺の挙動も面白かったのでコードを調査したいなあ・・・

疑問点

savevalidate: false渡しても無視されてない?

                    #ここ
def create_or_update(** , &block)

余談

今回ActiveRecord::Persistenceモジュールを調べていたのだが、本当にこのモジュールであっているのかどうか.method(:save).ownerで確認してたらsaveに関してはActiveRecord::Suppressorが出てきた。
これは一体・・・

0
0
0

Register as a new user and use Qiita more conveniently

  1. You get articles that match your needs
  2. You can efficiently read back useful information
  3. You can use dark theme
What you can do with signing up
0
0