Zenn
🔥

ActiveRecordの関連の設定漏れと削除時の外部制約違反を防ぐ(暫定版)

2025/02/28に公開

Ruby on Rails のモデルの関連設定漏れと、dependent option 漏れによるデータ削除時に発生する外部制約違反を生まれる前に消し去りたいと考えた。

雑味のある方法ではあるが暫定的に対処してみたので記録として残しておく。動かした環境は rails 8.0.1。

tl;dr

以下のことを実現して、関連の設定自体の漏れ、dependent option 未設定を検知できるようにした。

  1. モデルのクラスメソッド .reflections を使って関連情報を取得する
  2. ApplicationRecord.connection.tables でテーブル情報を取得する
  3. 関連情報と、テーブル情報から モデル名_id を持っているテーブル情報を抽出し、差分がないか確認するテストをつくる

テストの目的がズレているというのはそのとおりなのだが、暫定的でも早期に検知するできるように、処理をクラスとして、検知をテストとして実装することにした。

課題

モデルの関連は必ずしも双方向の設定が必須ではないため、利用する関連のみを記述してしまいがちである。
また、dependent option の書き漏れがあり、データ削除時に外部制約エラーが発生して削除処理に失敗することがある。
これらをリリース前に検知したい。

rubocop-rails の Rails/HasManyOrHasOneDependent

rubocop-rails の Rails/HasManyOrHasOneDependent は、関連の has_many/has_one を記述する際に dependent option の記述漏れがあった場合に警告を出す。

これは has_many/has_one を記述すると dependent option の設定漏れを警告してくれて非常に便利なのだが、記述自体をしなければ警告を出せない。
今回はhas_many/has_one関連自体の記述漏れも検知したいので、別の手段が必要になった。

ActiveRecord::Reflection::ClassMethods の reflections で関連情報を取得する

ApplicationRecord を継承したモデルにクラスメソッドとして定義されている .reflections を使うと、関連名を key、関連を示すクラスのインスタンスを value とした Hash を返すようだ。
has_many は ActiveRecord::Reflection::HasManyReflection、has_one は ActiveRecord::Reflection::HasOneReflection のインスタンスが設定される。これらインスタンスの options に、関連に設定した dependentclass_name 等のオプションが設定されている。

{"entries" =>
  #<ActiveRecord::Reflection::HasManyReflection:0x0000ffff67ac9560
   @active_record=Channel (call 'Channel.load_schema' to load schema informations),
   @association_foreign_key=nil,
   @association_primary_key=nil,
   @class_name=nil,
   @counter_cache_column=nil,
   @foreign_key=nil,
   @inverse_of=nil,
   @inverse_which_updates_counter_cache=nil,
   @inverse_which_updates_counter_cache_defined=false,
   @join_table=nil,
   @klass=nil,
   @name=:entries,
   @options={dependent: :destroy},
   @plural_name="entries",
   @scope=nil>,
 "workspace" =>
  #<ActiveRecord::Reflection::BelongsToReflection:0x0000ffff67b3fa08
   @active_record=Channel (call 'Channel.load_schema' to load schema informations),
   @association_foreign_key=nil,
   @association_primary_key=nil,
   @class_name=nil,
   @counter_cache_column=nil,
   @foreign_key=nil,
   @inverse_of=nil,
   @inverse_which_updates_counter_cache=nil,
   @inverse_which_updates_counter_cache_defined=false,
   @join_table=nil,
   @klass=nil,
   @name=:workspace,
   @options={},
   @plural_name="workspaces",
   @scope=nil>}

https://api.rubyonrails.org/classes/ActiveRecord/Reflection/ClassMethods.html#method-i-reflections

ApplicationRecord を継承したモデルクラスの定数を取得し、それぞれのモデルから.reflections を呼び出し、has_many/has_one の options をチェックすることで何かしらの判定ができそう。

ApplicationRecord.connection.tables から モデル名_id をもつテーブルを抽出する

ApplicationRecord.connection.tables を呼び出すと、DBにあるテーブル名一覧が取得できる。
テーブル名から table_name.classify.constantize でモデル定数を取得し、.attribute_names で取得したカラム名から調べたいモデルの モデル名_id があるテーブルを抽出する。

注意すべき点は、ApplicationRecord.connection.tables は DB に定義されているテーブル名を取得してくるので、ar_internal_metadata のような内部管理用のテーブル名も取得してしまうこと。必要ないものは自分で除外する必要がある。

class DependentOptionChecker
  OUT_OF_CONTROL_TABLES = %w[
    data_migrations schema_migrations ar_internal_metadata
  ].freeze

  # 自分たちで追加したテーブル名
  def self.application_tables
    ::ApplicationRecord.connection.tables - OUT_OF_CONTROL_TABLES
  end

  # 自分たちで追加したテーブルのモデル定数
  # テスト実行時に使用する
  def self.application_table_models
    application_tables.map do |name|
      name.classify.constantize
    end
  end

  # attr_name の属性を含むテーブル名を取得する
  def self.tables_include_attr(attr_name)
    application_tables.select do |name|
      name.classify.constantize.attribute_names.include?(attr_name)
    rescue NameError
      # no-op
    end
  end
end

関連情報と、テーブル情報から モデル名_id を持っているテーブル情報を抽出する

前述のように .reflections は 関連名の文字列を key, 関連情報のインスタンスが value の Hash を返す。この value から、dependent option があるものを抽出する。

ただし、今回は以下の場合は抽出対象から除外する。

  • value が has_one/has_many/has_many through の関連情報を表すクラスでない場合
  • as option が指定されている場合 (polymorphic 関連は モデル名_id にならないため)
  • foreign_keyclass_name option が設定されている場合 (テーブル名と一致しないため)

foreign_key は外部制約が設定されている場合があるが、モデル名_id をもつテーブル名を取得する処理から関連の foreign_key option の情報にアクセスする方法がないため諦めた。

class DependentOptionChecker
  TARGET_CLASSES = [
    ActiveRecord::Reflection::HasOneReflection,
    ActiveRecord::Reflection::HasManyReflection,
    ActiveRecord::Reflection::ThroughReflection
  ].freeze

  # --- 中略 ---

  # モデルに定義されている関連のうち、dependent オプションが指定されているもののテーブル名の配列を返す
  # polymorphic な関連は一旦考慮外とする。(外部制約がないと思われるため)
  def self.relation_tables(klass)
    klass.reflections.filter_map do |table_name, reflection|
      next if TARGET_CLASSES.exclude?(reflection.class)
      next if reflection.options[:dependent].blank?
      next if reflection.options[:as].present?

      # NOTE: through しているテーブルも klass名_id のカラムを持っていることはあるため
      if reflection.options[:through].present?
        attr_name = "#{klass.name.underscore}_id"
        next if table_name.classify.constantize.attribute_names.exclude?(attr_name)
      end

      # NOTE: foreign_key option や class_name option のあるものはモデル名の複数形がテーブル名と一致しないため除外する
      # 例)
      # next if klass == User && reflection.plural_name.in?(%w[admin_permissions member_permissions])

      # テーブル名を返したいため、has_one の場合は plural_name を返す
      reflection.has_one? ? reflection.plural_name : table_name
    rescue NameError
      # no-op
    end
  end
end

関連情報と、モデル名_idをもつテーブルを抽出して差分を確認するテストを作る

前述の DependentOptionChecker のテストを作成し、モデルクラスの関連情報から抽出したテーブル名と、モデル名_id のカラムをもつテーブル名を抽出して比較するようにした。

require 'rails_helper'

RSpec.describe DependentOptionChecker, type: :model do
  # NOTE: テーブルが増えても都度手を加えなくてもよいようにテーブル情報取得してループする
  #       ループで処理しやすいように shared_example を使う
  RSpec.shared_examples 'having dependent option' do |klass|
    it do
      attr_name = "#{klass.name.underscore}_id"
      expect(described_class.tables_include_attr(attr_name)).to match_array described_class.relation_tables(klass)
    end
  end

  described_class.application_table_models.each do |klass|
    # NOTE: どのクラスの問題かわかるように describe で クラス名 を明示する
    describe "\"#{klass}\"" do
      it_behaves_like 'having dependent option', klass
    end
  end
end

実行すると以下のようなエラーを出すので、dependent 設定漏れ、もしくは、関連の宣言自体に漏れがあるとわかる。

expected にない場合は dependent の設定が漏れており、actual にない場合は typo や除外設定漏れが考えられる。

$ bundle exec rspec spec/lib/dependent_option_checker_spec.rb
.F.F

Failures:
  1) DependentOptionChecker "Channel" behaves like having dependent option is expected to contain exactly
     Failure/Error: expect(described_class.tables_include_attr(attr_name)).to match_array described_class.relation_tables(klass)

       expected collection contained:  []
       actual collection contained:    ["entries"]
       the extra elements were:        ["entries"]
     Shared Example Group: "having dependent option" called from ./spec/lib/dependent_option_checker_spec.rb:15
     # ./spec/lib/dependent_option_checker_spec.rb:9:in 'block (3 levels) in <main>'

  2) DependentOptionChecker "User" behaves like having dependent option is expected to contain exactly
     Failure/Error: expect(described_class.tables_include_attr(attr_name)).to match_array described_class.relation_tables(klass)

       expected collection contained:  []
       actual collection contained:    ["entries"]
       the extra elements were:        ["entries"]
     Shared Example Group: "having dependent option" called from ./spec/lib/dependent_option_checker_spec.rb:15
     # ./spec/lib/dependent_option_checker_spec.rb:9:in 'block (3 levels) in <main>'

まとめ

モデルクラスに定義されている .reflections を使い、dependent option の設定漏れや関連設定漏れを検出して気がつけるような仕組みを作った。

取り急ぎ検出できるようにしたということもあり、目的とズレた単体テストというかたちで実装している点、foreign_key option 等検出漏れが発生する点に不満はあるが、一定の効果は果たしている。

rake task 化する等、テストとして実装したがために対処できなかった部分を改善し、GitHub Actions で検出するようにする等やれることはまだありそう。

あしたのチーム Tech Blog

Discussion

ログインするとコメントできます