💨

ignored_columns の削除漏れを検出する rake task を実装する

に公開

テーブルのカラムを削除したいとなった際、稼働中のシステムに影響を与えないよう ApplciationRecord を継承したモデルの self.ignored_columns をオーバーライドして対象のカラムを追加し、該当カラムを無視して無きものとして扱うことがある。

追加したのはよいものの、カラムの削除を忘れたりカラムを削除してオーバーライドした self.ignored_columns を削除し忘れたりすることがあるため、そうなった場合に気がつける仕組みを用意したいと考えたので、実践した内容を記録として残しておく。

self.ignored_columns を設定しているモデルを取得する

ApplicationRecord.descendants で、データベースのテーブルに対応したモデルクラスを取得し、そのクラスの self.ignored_columns の配列の中身が空でないものを抽出することでこれを実現する。

ただし、あらかじめモデルをロードしておかないと、ApplicationRecord.descendants が結果を返さないため、Rails.autoloaders.main.eager_load_dir(Rails.root.join('app', 'models')) を実行するようにした。

001:0: development > Rails.autoloaders.main.eager_load_dir(Rails.root.join('app', 'models'))
:  nil
002:0: development > ApplicationRecord.descendants
:
[Workspace (call 'Workspace.load_schema' to load schema informations),
 User (call 'User.load_schema' to load schema informations),
 Entry (call 'Entry.load_schema' to load schema informations),
 Channel (call 'Channel.load_schema' to load schema informations)]

これでテーブルに対応したモデルクラスの一覧が取得できる。self.ignored_columns の配列に要素が存在するかどうか、もしくは、要素が存在しないものを除外することで対象のモデル抽出できる。

003:0: development > klasses = ApplicationRecord.descendants.reject { |klass| klass.ignored_columns.empty? }
:  [Workspace (call 'Workspace.load_schema' to load schema informations), ...]

※ ここでは、Workspace.ignored_columns に updated_at を設定している。

self.ignored_columns に設定したカラムが実際にテーブルに存在しているかを調べる

self.ignored_columns に設定したカラムが実際のテーブルに存在しているかどうかがわかれば、無視しているのか、self.ignored_columns の削除漏れかを判断できる。

ApplicationRecord.connection.columns("テーブル名") を使うことで、無視しているかどうかにかかわらずテーブルに定義されているカラムの情報が取得できる。
モデルクラスから取得しようとする(例: Workspace.columns_hash)と、無視するカラムを含まないカラム情報を返してくるため、connection を使って現存するカラム情報を取得している。

https://api.rubyonrails.org/classes/ActiveRecord/ConnectionAdapters/SchemaStatements.html#method-i-columns

:004:0: development  > ApplicationRecord.connection.method(:columns).source_location
:  [".../vendor/bundle/ruby/3.4.0/gems/activerecord-8.0.2/lib/active_record/connection_adapters/abstract/schema_statements.rb", 107]
:005:0: development  > ApplicationRecord.connection.columns(Workspace.table_name)
:
[#<ActiveRecord::ConnectionAdapters::SQLite3::Column:0x0000000121a1c260
  @auto_increment=true,
  @collation=nil,
  @comment=nil,
  @default=nil,
  @default_function=nil,
  @generated_type=nil,
  @name="id",
  @null=false,
  @rowid=true,
  @sql_type_metadata=#<ActiveRecord::ConnectionAdapters::SqlTypeMetadata:0x00000001221f88f8 @limit=nil, @precision=nil, @scale=nil, @sql_type="INTEGER", @type=:integer>>,
 #<ActiveRecord::ConnectionAdapters::SQLite3::Column:0x0000000121a1ba40
  @auto_increment=nil,
  @collation=nil,
  @comment=nil,
  @default=nil,
  @default_function=nil,
  @generated_type=nil,
  @name="name",
  @null=false,
  @rowid=false,
  @sql_type_metadata=#<ActiveRecord::ConnectionAdapters::SqlTypeMetadata:0x00000001221f84e8 @limit=nil, @precision=nil, @scale=nil, @sql_type="varchar", @type=:string>>,
 #<ActiveRecord::ConnectionAdapters::SQLite3::Column:0x0000000121a1a6e0
  @auto_increment=nil,
  @collation=nil,
  @comment=nil,
  @default=nil,
  @default_function=nil,
  @generated_type=nil,
  @name="created_at",
  @null=false,
  @rowid=false,
  @sql_type_metadata=#<ActiveRecord::ConnectionAdapters::SqlTypeMetadata:0x00000001221f7f48 @limit=nil, @precision=6, @scale=nil, @sql_type="datetime(6)", @type=:datetime>>,
 #<ActiveRecord::ConnectionAdapters::SQLite3::Column:0x000000012d6da000
  @auto_increment=nil,
  @collation=nil,
  @comment=nil,
  @default=nil,
  @default_function=nil,
  @generated_type=nil,
  @name="updated_at",
  @null=false,
  @rowid=false,
  @sql_type_metadata=#<ActiveRecord::ConnectionAdapters::SqlTypeMetadata:0x000000012ea33200 @limit=nil, @precision=6, @scale=nil, @sql_type="datetime(6)", @type=:datetime>>,
  ...
]

実行すると指定したテーブル名のカラム情報をもったインスタンスの配列が返ってくるため、要素の name メソッドを呼び出してカラム名を抽出する。
この中に、self.ignored_columns に設定されているカラムが存在していれば無視している、存在していなければ self.ignored_columns の削除漏れ、ということになる。

:006:0: development  > Workspace.ignored_columns.reject { |name| ApplicationRecord.connection.columns(Workspace.table_name).map(&:name).include?(name) }
:  []          # ignored_columns に設定したカラムが含まれない   => テーブルにカラムが存在している   => 無視しているだけ
:007:0: development  > Workspace.ignored_columns.reject { |name| ApplicationRecord.connection.columns(Workspace.table_name).map(&:name).include?(name) }
:  ["upadted_at"] # ignored_columns に設定したカラムが含まれている => テーブルにカラムが存在していない => ignored_columns からの削除漏れ

検出する処理を作る

今回は rake タスクとして実装し、標準出力に出力するものとする。
実装したタスクを GitHub Actions で定期実行してエラーとして失敗させたり Slack 等に通知したりするのもよいが、ここでは通知方法は主題ではないため、標準出力に出力するだけとする。

namespace :db do
  desc 'Detect ignored_columns'
  task detect_ignored_columns: :environment do
    # schema 情報を返すようにモデルをあらかじめロードしておく
    Rails.autoloaders.main.eager_load_dir(Rails.root.join('app/models'))

    # self.ignored_columns が空でないモデルクラスのみを抽出する
    klasses = ApplicationRecord.descendants.reject { |klass| klass.ignored_columns.empty? }

    exit if klasses.empty?

    klasses.each do |klass|
      ignored_columns = klass.ignored_columns
      raw_columns = klass.connection.columns(klass.table_name).map(&:name) # テーブルに存在するカラム名
      omissions = ignored_columns.reject { |col| raw_columns.include?(col) } # ignored_columns から削除が漏れているカラム名

      puts <<~TXT
        #{klass.name}:
        - 無視されているカラム: #{(raw_columns & ignored_columns).join(', ')}
        - コードのみの削除漏れ: #{omissions.empty? ? 'なし' : omissions.join(', ')}
      TXT
    end
    exit(1)
  end
end
$ bin/rake db:detect_ignored_columns
Workspace:
- 無視されているカラム: updated_at
- コードのみの削除漏れ: なし

$ echo $?
1

まとめ

  • self.ignored_columns を設定しているモデルを抽出した
  • 該当のカラムがデータベースのテーブルに残っているかどうかを調べ、無視しているだけなのか削除漏れなのかを判別できるようにした
  • ModelClass.columns は無視したカラムを含まず、ApplicationRecord.connection.columns(table_name) は無視したカラムを含んだカラム情報を返す
  • 結果を標準出力に出力する、rake task の実装例を示した
あしたのチーム Tech Blog

Discussion