前提
Rails 6.1.4
ruby 2.7.1
はじめに
ActiveRecordのupdateメソッドを使う際、引数に一部の変更したいカラムだけ指定する時があるが、この時指定されていないattirubteはnilとなりバリデーションに引っかかる場合があると思うがどうやって回避されているのだろうか?という疑問を持ったので挙動について詳しく調べていきたいと思う。
今回以下のソースコードを参考にした。
この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
始めに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などを追加すればそれも含まれて返されるのかは調査が必要。
attributes_for_createは、先ほどのattribute_namesを引数に渡して実行している。これは、attribute_namesの中から、次の条件「idがnilかつprimary key」を満たすattributeを削除したattirubte_namesを返す仕様になっている。
attributes_with_valuesにより、{"title": "test"}のような属性とその値をハッシュに変換したものをself.class._insert_recordに渡している。
_insert_recordでは、最初にattribute_namesにidがない場合に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_with_valuesで属性名をキーに、値をバリューにしたハッシュを取得し、_update_recordに渡している。
先ほどと同様に一旦_update_recordについて読み解くのはスルーして関数名からおそらくDBに対して更新処理を行なっているという予測に留める。
最後に返り値としてaffected_rowを返しているが、これは変数名から変更されたカラム数だと思う。。
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
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にする更新処理のみ。
ここら辺の挙動も面白かったのでコードを調査したいなあ・・・
疑問点
saveにvalidate: false渡しても無視されてない?
#ここ
def create_or_update(** , &block)
余談
今回ActiveRecord::Persistenceモジュールを調べていたのだが、本当にこのモジュールであっているのかどうか.method(:save).ownerで確認してたらsaveに関してはActiveRecord::Suppressorが出てきた。
これは一体・・・