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

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

実装したモデル:

```user.rb
class User < ApplicationRecord
  has_many :microposts
end
```

```micropost.rb
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`メソッドの仕様を確認します。

https://apidock.com/rails/ActiveRecord/Associations/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
```
https://github.com/rails/rails/blob/dc7817cb42768ad01d69d5f9b0023f556760cb06/activerecord/lib/active_record/associations/collection_proxy.rb#L472

`@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
```
https://github.com/rails/rails/blob/dc7817cb42768ad01d69d5f9b0023f556760cb06/activerecord/lib/active_record/associations/collection_association.rb#L139

引数の`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_many`に`dependent`オプションがないので、`dependent`は`nil`となります。
最後に`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
```
https://github.com/rails/rails/blob/dc7817cb42768ad01d69d5f9b0023f556760cb06/activerecord/lib/active_record/associations/has_many_association.rb#L112

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

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

https://github.com/rails/rails/blob/005e14b2c4c403c7a85902f00d58c32bfc9ecd6c/activerecord/lib/active_record/associations/collection_association.rb#L286

親クラスの`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
```

https://github.com/rails/rails/blob/005e14b2c4c403c7a85902f00d58c32bfc9ecd6c/activerecord/lib/active_record/associations/association.rb#L99

`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
```
https://github.com/rails/rails/blob/005e14b2c4c403c7a85902f00d58c32bfc9ecd6c/activerecord/lib/active_record/associations/collection_association.rb#L292

今回は新規レコードではなく、外部キーを持っているので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`メソッドを見ていきます。

```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
```
https://github.com/rails/rails/blob/dc7817cb42768ad01d69d5f9b0023f556760cb06/activerecord/lib/active_record/associations/has_many_association.rb#L104

ここで`method`が、つまりはdependentが`:delete_all`であるかどうかで、deleteかupdateかに分かれるようです。

今回`method`は`nil`なので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
```
https://github.com/rails/rails/blob/005e14b2c4c403c7a85902f00d58c32bfc9ecd6c/activerecord/lib/active_record/associations/foreign_association.rb#L13

このメソッドで`Micropost`の外部キー(`user_id`)を`nil`として持つHashを作成します。
nullified_owner_attributesメソッドの確認が終わったので、`scope`の`update_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
```
https://github.com/rails/rails/blob/005e14b2c4c403c7a85902f00d58c32bfc9ecd6c/activerecord/lib/active_record/relation.rb#L464

このメソッドでは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
```
https://github.com/rails/rails/blob/005e14b2c4c403c7a85902f00d58c32bfc9ecd6c/activerecord/lib/active_record/relation.rb#L744

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
```

https://github.com/rails/rails/blob/005e14b2c4c403c7a85902f00d58c32bfc9ecd6c/activerecord/lib/active_record/connection_adapters/abstract/database_statements.rb#L173

この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]
```

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

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