前提
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
が出てきた。
これは一体・・・