6
0

Delete article

Deleted articles cannot be recovered.

Draft of this article would be also deleted.

Are you sure you want to delete this article?

More than 3 years have passed since last update.

【コードリーディング】delete_allでupdateやdeleteを実行するコードはどこなのか

Posted at

はじめに

以下モデルを実装し、サンプルデータを詰めてdelete_allメソッドを実行した際、deleteクエリではなく、updateクエリが実行されました。

実装したモデル:

user.rb
class User < ApplicationRecord
  has_many :microposts
end
micropost.rb
class Micropost < ApplicationRecord
  belongs_to :user 
end

サンプルデータ:
useruser.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から読んでいきます。

collection_proxy.rb
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クラスを見ていきます。

collection_association.rb
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にあたります。

hoge.rb
class Hoge < ApplicationRecord
  has_many :foos, dependent: :delete_all
end

今回はhas_manydependentオプションがないので、dependentnilとなります。
最後にdelete_or_nullify_all_recordメソッドを呼びだします。

has_many_association.rb
def delete_or_nullify_all_records(method)
  count = delete_count(method, scope)
  update_counter(-count)
  count
end

scopeが新しく出てきたので、その定義を見てみます。

collection_association.rb
def scope
  scope = super
  scope.none! if null_scope?
  scope
end

親クラスのscopeを取得しているので、その定義を見ます。

association.rb
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メソッドを見ていきます。

association.rb
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?メソッドを見ていきます。

collection_association.rb
def null_scope?
  owner.new_record? && !foreign_key_present?
end

今回は新規レコードではなく、外部キーを持っているのでfalseが返されます。

binding.pryの実行結果:

> owner
=> #<User:0x00007ffe81de2fb0
 id: 1,
・・・
> owner.new_record?
=> false

そのため、scopeAssociationRelationクラスのインスタンスのままとなります。

ではhas_many_association.rbのdelete_or_nullify_all_recordsメソッドに戻り、delete_countメソッドを見ていきます。

has_many_association.rb
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かに分かれるようです。

今回methodnilなのでupdateとなるのですが、deleteクエリの実行も確認したいため、それぞれのルートを確認します。

updateのルート

scope.update_all(nullified_owner_attributes)が実行されるので、まずはnullified_owner_attributesメソッドを見ていきます。

foreign_association.rb
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メソッドの確認が終わったので、scopeupdate_allメソッドを見ていきます。

relation.rb
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?メソッドは以下で定義されています。

relation.rb
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メソッドは以下のようになっています。

relation.rb
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にあることが分かります。

database_statements.rb
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のルートを確認します。

has_many_association.rb
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メソッドを確認します。

relation.rb
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]

そのため、ここではdistinctgroupなどが含まれていれば、delete_allメソッドを実行せず例外を投げる処理となります。今回は含まれていないため、スキップします。
その後のeager_loading?メソッドやstmtの条件セット処理はupdateとほぼ同じのため、スキップします。

最後に@klass.connection.deletedeleteメソッドを実行しています。
@klass.connectionはupdateの時と同じくMysql2Adapterであり、deleteメソッドはupdateメソッドの定義と同じDatabaseStatementsにあります。

database_statements.rb
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が分岐しているのか知ることができました。
コードの中身を知っておくと、あのコードが動いているのだなとイメージできて良いと感じました。
この記事が誰かのお役に立てれば幸いです。

6
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
6
0

Delete article

Deleted articles cannot be recovered.

Draft of this article would be also deleted.

Are you sure you want to delete this article?