[Rails]関連先のレコードをdelete_allしたらMysql2::Error: Column cannot be null

2021/09/05に公開

foo.hoges.delete_allという風にhas_manyで関連付けされてるレコードをdelete_allしようとしたときにエラーが発生したのでその対処法です。

以下のような1対多のテーブル、Modelがあるとします。

class DeleteAllParent < ApplicationRecord
  has_many :delete_all_children
end

class DeleteAllChild < ApplicationRecord
  belongs_to :delete_all_parent
end
CREATE TABLE `delete_all_parents` (
  `id` bigint(20) NOT NULL AUTO_INCREMENT,
  PRIMARY KEY (`id`)
) ENGINE=InnoDB AUTO_INCREMENT=2 DEFAULT CHARSET=utf8mb4;

CREATE TABLE `delete_all_children` (
  `id` bigint(20) NOT NULL AUTO_INCREMENT,
  `delete_all_parent_id` bigint(20) NOT NULL,
  PRIMARY KEY (`id`),
  KEY `index_delete_all_children_on_delete_all_parent_id` (`delete_all_parent_id`),
  CONSTRAINT `fk_rails_ae902ddcaf` FOREIGN KEY (`delete_all_parent_id`) REFERENCES `delete_all_parents` (`id`)
) ENGINE=InnoDB AUTO_INCREMENT=3 DEFAULT CHARSET=utf8mb4;

子のレコードを物理削除しようと思い、

DeleteAllParent.first.delete_all_children.delete_all

とすると、

ActiveRecord::NotNullViolation: Mysql2::Error: Column 'delete_all_parent_id' cannot be null

とエラーが発生し、削除が失敗します。

ログを見てみると

[3] pry(main)> DeleteAllParent.first.delete_all_children.delete_all
  DeleteAllParent Load (0.7ms)  SELECT `delete_all_parents`.* FROM `delete_all_parents` ORDER BY `delete_all_parents`.`id` ASC LIMIT 1
  DeleteAllChild Update All (5.8ms)  UPDATE `delete_all_children` SET `delete_all_children`.`delete_all_parent_id` = NULL WHERE `delete_all_children`.`delete_all_parent_id` = 1

delete_all_childrenに対してdeleteではなくupdate処理が走っているのがわかります。
delete_all_children.delete_all_parent_idカラムはNOT NULLとしてるのでエラーが起きているようです。

deleteの処理が走るのかなと思ったらupdate処理だったのでなぜ?と思ってコードを見てみます。

ActiveRecordのコードを追ってみる

[8] pry(main)> DeleteAllParent.first.delete_all_children.class
  DeleteAllParent Load (2.8ms)  SELECT `delete_all_parents`.* FROM `delete_all_parents` ORDER BY `delete_all_parents`.`id` ASC LIMIT 1
=> DeleteAllChild::ActiveRecord_Associations_CollectionProxy

ActiveRecord_Associations_CollectionProxyクラスを見に行きます。
delete_all生えてました。これですね。

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/main/activerecord/lib/active_record/associations/collection_proxy.rb#L472-L474

コードを追っていくと

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

https://github.com/rails/rails/blob/9c77d72ecc7fdf490e778b0807882fd53115e7a3/activerecord/lib/active_record/associations/has_many_association.rb#L112-L116

ここでmethodによってdeleteかupdateか分けていました。

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/9c77d72ecc7fdf490e778b0807882fd53115e7a3/activerecord/lib/active_record/associations/has_many_association.rb#L104-L110

ここのmethoddelete_allメソッドのdependentにセットされているものです。
今回の場合だとdependentnilなので、update_allになっていたいうことがわかりました。

どうすればdeleteされるか

どういうときにdelete_allになるかというと、
delete_allに引数として:delete_allを渡す。
または、
options[:dependent]に:delete_allor:destroyがセットされた状態にする。つまり、

class DeleteAllParent < ApplicationRecord
  has_many :delete_all_children, dependent: :delete_all
  # or
  has_many :delete_all_children, dependent: :destroy
end

dependentを設定しておく。
これで無事関連先のレコードを物理削除することができるようになりました。

Discussion