はじめに
以下モデルを実装し、サンプルデータを詰めてdelete_all
メソッドを実行した際、deleteクエリではなく、updateクエリが実行されました。
実装したモデル:
class User < ApplicationRecord
has_many :microposts
end
class Micropost < ApplicationRecord
belongs_to :user
end
サンプルデータ:
user
、user.microposts
にそれぞれサンプルデータが入っています。
> user
=> #<User id: 1, name: "test_user", email: "test@email.com", created_at: "2021-03-09 13:39:20.444759000 +0000", updated_at: "2021-03-09 13:39:20.444759000 +0000">
> user.microposts
Micropost Load (1.0ms) SELECT `microposts`.* FROM `microposts` WHERE `microposts`.`user_id` = 1 /* loading for inspect */ LIMIT 11
=> #<ActiveRecord::Associations::CollectionProxy [#<Micropost id: 1, content: "test content", user_id: 1, created_at: "2021-03-09 13:42:16.405694000 +0000", updated_at: "2021-03-09 13:42:16.405694000 +0000">]>
user.microposts.delete_all
の実行結果:
> user.microposts.delete_all
Micropost Update All (3.6ms) UPDATE `microposts` SET `microposts`.`user_id` = NULL WHERE `microposts`.`user_id` = 1
=> 1
このupdateクエリがどの条件のときにどんなコードで実行されるのか、またdeleteのときはどうなるのか気になったため、調べてみました。
環境
Ruby: 3.0.0
Ruby on Rails: 6.1.3
コード確認日: 2020/03/08
delete_all実行時のクラスを把握
user.microposts
のクラスを調べてみると、CollectionProxy
になっていました。
> user.microposts.class
=> Micropost::ActiveRecord_Associations_CollectionProxy
そのためCollectionProxy
クラスでdelete_all
メソッドが実行されているようです。
仕様を読む
CollectionProxy
クラスのdelete_all
メソッドの仕様を確認します。
Deletes all the records from the collection according to the strategy specified by the :dependent option. If no :dependent option is given, then it will follow the default strategy.
For has_many :through associations, the default deletion strategy is :delete_all.
For has_many associations, the default deletion strategy is :nullify. This sets the foreign keys to NULL.
dependent
オプションの指定内容によって挙動が決まるようです。もしdependent
オプションがなければ、associationの形式によって挙動が異なるようです。
今回はdependent
オプションがなく、has_many
のassociationだけなので、デフォルトの挙動であるnullify(外部キーがNULLになる)となります。
コードを読む
実際のコードとbinding.pryを使用して、コードを読んでいきます。
まずはCollectionProxy
から読んでいきます。
def delete_all(dependent = nil)
@association.delete_all(dependent).tap { reset_scope }
end
@association
は、User
モデルで関連付けしているhas_many
があるので、HasManyAssociation
となります。
binding.pryでの確認結果:
> @association
=> #<ActiveRecord::Associations::HasManyAssociation:0x00007fa9578df970
・・・
そのため、HasManyAssociation
クラスのdelete_all
メソッドを呼び出します。
HasManyAssociation
クラス自体ではdelete_all
メソッドが定義されていないので、親クラスのCollectionAssociation
クラスを見ていきます。
def delete_all(dependent = nil)
if dependent && ![:nullify, :delete_all].include?(dependent)
raise ArgumentError, "Valid values are :nullify or :delete_all"
end
dependent = if dependent
dependent
elsif options[:dependent] == :destroy
:delete_all
else
options[:dependent]
end
delete_or_nullify_all_records(dependent).tap do
reset
loaded!
end
end
引数のdependent
が存在し、:nullify
や:delete_all
を含んでいなければ例外を返しますが、今回は引数のdependent
を設定していないので次に進みます。
次の処理では、delete_or_nullify_all_record
メソッドに渡すdependent
をセットしています。ここでのoptions[:dependent]
は、以下のようなHogeモデルのdependent
の :delete_all
にあたります。
class Hoge < ApplicationRecord
has_many :foos, dependent: :delete_all
end
今回はhas_many
にdependent
オプションがないので、dependent
はnil
となります。
最後にdelete_or_nullify_all_record
メソッドを呼びだします。
def delete_or_nullify_all_records(method)
count = delete_count(method, scope)
update_counter(-count)
count
end
scope
が新しく出てきたので、その定義を見てみます。
def scope
scope = super
scope.none! if null_scope?
scope
end
親クラスのscope
を取得しているので、その定義を見ます。
def scope
if (scope = klass.current_scope) && scope.try(:proxy_association) == self
scope.spawn
else
target_scope.merge!(association_scope)
end
end
klass
をbinding.pryで確認するとMicropost
になっており、klass.current_scope
を確認するとnil
になっています。
binding.pryの実行結果:
> klass
=> Micropost(id: integer, content: string, user_id: integer, created_at: datetime, updated_at: datetime)
> klass.current_scope
=> nil
そのため、target_scope
メソッドを見ていきます。
def target_scope
AssociationRelation.create(klass, self).merge!(klass.scope_for_association)
end
target_scope
ではAssociationRelation
クラスをnewしているので、scope
にはAssociationRelation
クラスのインスタンスがセットされます。
collection_association.rbのscope
メソッドに戻り、null_scope?
メソッドを見ていきます。
def null_scope?
owner.new_record? && !foreign_key_present?
end
今回は新規レコードではなく、外部キーを持っているのでfalseが返されます。
binding.pryの実行結果:
> owner
=> #<User:0x00007ffe81de2fb0
id: 1,
・・・
> owner.new_record?
=> false
そのため、scope
はAssociationRelation
クラスのインスタンスのままとなります。
ではhas_many_association.rbのdelete_or_nullify_all_records
メソッドに戻り、delete_count
メソッドを見ていきます。
def delete_count(method, scope)
if method == :delete_all
scope.delete_all
else
scope.update_all(nullified_owner_attributes)
end
end
ここでmethod
が、つまりはdependentが:delete_all
であるかどうかで、deleteかupdateかに分かれるようです。
今回method
はnil
なのでupdateとなるのですが、deleteクエリの実行も確認したいため、それぞれのルートを確認します。
updateのルート
scope.update_all(nullified_owner_attributes)
が実行されるので、まずはnullified_owner_attributes
メソッドを見ていきます。
def nullified_owner_attributes
Hash.new.tap do |attrs|
attrs[reflection.foreign_key] = nil
attrs[reflection.type] = nil if reflection.type.present?
end
end
このメソッドでMicropost
の外部キー(user_id
)をnil
として持つHashを作成します。
nullified_owner_attributesメソッドの確認が終わったので、scope
のupdate_all
メソッドを見ていきます。
def update_all(updates)
raise ArgumentError, "Empty list of attributes to change" if updates.blank?
if eager_loading?
relation = apply_join_dependency
return relation.update_all(updates)
end
stmt = Arel::UpdateManager.new
stmt.table(arel.join_sources.empty? ? table : arel.source)
stmt.key = table[primary_key]
stmt.take(arel.limit)
stmt.offset(arel.offset)
stmt.order(*arel.orders)
stmt.wheres = arel.constraints
if updates.is_a?(Hash)
if klass.locking_enabled? &&
!updates.key?(klass.locking_column) &&
!updates.key?(klass.locking_column.to_sym)
attr = table[klass.locking_column]
updates[attr.name] = _increment_attribute(attr)
end
stmt.set _substitute_values(updates)
else
stmt.set Arel.sql(klass.sanitize_sql_for_assignment(updates, table.name))
end
@klass.connection.update stmt, "#{@klass} Update All"
end
このメソッドでは3つの大きな括りができているようなので、順番に見ていきます。
if eager_loading?
relation = apply_join_dependency
return relation.update_all(updates)
end
このeager_loading?
メソッドは以下で定義されています。
def eager_loading?
@should_eager_load ||=
eager_load_values.any? ||
includes_values.any? && (joined_includes_values.any? || references_eager_loaded_tables?)
end
eager_loadやincludeを行っていれば、trueとして返されるようです。今回は使用していないのでfalseが返されます。次はArel::UpdateManager.new
を使用した処理になります。
stmt = Arel::UpdateManager.new
stmt.table(arel.join_sources.empty? ? table : arel.source)
stmt.key = table[primary_key]
stmt.take(arel.limit)
stmt.offset(arel.offset)
stmt.order(*arel.orders)
stmt.wheres = arel.constraints
if updates.is_a?(Hash)
if klass.locking_enabled? &&
!updates.key?(klass.locking_column) &&
!updates.key?(klass.locking_column.to_sym)
attr = table[klass.locking_column]
updates[attr.name] = _increment_attribute(attr)
end
stmt.set _substitute_values(updates)
else
stmt.set Arel.sql(klass.sanitize_sql_for_assignment(updates, table.name))
end
ここで、実行するクエリの条件をセットしています。
引数で渡されたupdates
はHashのためif文に入ります。locking_enabled?
は楽観的ロックをしているかどうかの判定ですが、今回は行っていないため、_substitute_values
メソッドの実行結果をセットします。
_substitute_values
メソッドは以下のようになっています。
def _substitute_values(values)
values.map do |name, value|
attr = table[name]
unless Arel.arel_node?(value)
type = klass.type_for_attribute(attr.name)
value = predicate_builder.build_bind_attribute(attr.name, type.cast(value))
end
[attr, value]
end
end
このメソッドではvalue
の中身をチェックして、属性と値をセットとして持たせています。
stmt
に条件をセットし終えたので、update
メソッドを実行します。
@klass.connection.update stmt, "#{@klass} Update All"
@klass.connection
をbinding.pryで確認するとMysql2Adapter
になっています。
binding.pryの実行結果:
> @klass.connection
=> #<ActiveRecord::ConnectionAdapters::Mysql2Adapter:0x00007fd889132748
・・・
Mysql2Adapter
クラス自体にはupdate
メソッドがないため、親クラスをたどるとDatabaseStatements
にあることが分かります。
def update(arel, name = nil, binds = [])
sql, binds = to_sql_and_binds(arel, binds)
exec_update(sql, name, binds)
end
このupdateメソッドで、to_sql_and_binds
メソッドを使用してupdateのSQLを作り、実行となります。
deleteのルート
updateの確認が終わったので、次はdeleteのルートを確認します。
def delete_count(method, scope)
if method == :delete_all
scope.delete_all
else
scope.update_all(nullified_owner_attributes)
end
end
scope.delete_all
の実行となるので、delete_all
メソッドを確認します。
def delete_all
invalid_methods = INVALID_METHODS_FOR_DELETE_ALL.select do |method|
value = @values[method]
method == :distinct ? value : value&.any?
end
if invalid_methods.any?
raise ActiveRecordError.new("delete_all doesn't support #{invalid_methods.join(', ')}")
end
if eager_loading?
relation = apply_join_dependency
return relation.delete_all
end
stmt = Arel::DeleteManager.new
stmt.from(arel.join_sources.empty? ? table : arel.source)
stmt.key = table[primary_key]
stmt.take(arel.limit)
stmt.offset(arel.offset)
stmt.order(*arel.orders)
stmt.wheres = arel.constraints
affected = @klass.connection.delete(stmt, "#{@klass} Destroy")
reset
affected
end
最初の部分が異なりますが、あとはupdateのときとほぼ同じになります。
まずは最初の部分を見ていきます。
invalid_methods = INVALID_METHODS_FOR_DELETE_ALL.select do |method|
value = @values[method]
method == :distinct ? value : value&.any?
end
if invalid_methods.any?
raise ActiveRecordError.new("delete_all doesn't support #{invalid_methods.join(', ')}")
end
NVALID_METHODS_FOR_DELETE_ALL
は、同じクラスで以下のように定義されています。
INVALID_METHODS_FOR_DELETE_ALL = [:distinct, :group, :having]
そのため、ここではdistinct
やgroup
などが含まれていれば、delete_all
メソッドを実行せず例外を投げる処理となります。今回は含まれていないため、スキップします。
その後のeager_loading?
メソッドやstmt
の条件セット処理はupdateとほぼ同じのため、スキップします。
最後に@klass.connection.delete
でdelete
メソッドを実行しています。
@klass.connection
はupdateの時と同じくMysql2Adapter
であり、delete
メソッドはupdate
メソッドの定義と同じDatabaseStatements
にあります。
def delete(arel, name = nil, binds = [])
sql, binds = to_sql_and_binds(arel, binds)
exec_delete(sql, name, binds)
end
こちらもto_sql_and_binds
メソッドで、deleteのSQLを作り、実行となります。
おわりに
コードリーディングを行うことで、ドキュメントだけでなく、どこのコードでdeleteとupdateが分岐しているのか知ることができました。
コードの中身を知っておくと、あのコードが動いているのだなとイメージできて良いと感じました。
この記事が誰かのお役に立てれば幸いです。