ActiveRecordモデルのカラムを消すときにignored_columnsが必要な理由

自己紹介

株式会社Progateサーバーサイドエンジニアのもりたんざにあです。 好きなものは卓球と筋トレです。 業務でignored_columnsの必要性や、内部で何をしているかについて調べたので、その過程でわかったことを紹介しようと思います。

前提条件

この記事の内容は https://github.com/rails/rails/tree/5-2-stable のソースコードを前提としています。

ignored_columnsとは

公式ドキュメンテーション : https://api.rubyonrails.org/classes/ActiveRecord/ModelSchema/ClassMethods.html#method-i-ignored_columns

「このカラムがデータベースのテーブルにあっても、無視してね!」とRailsのモデルに伝えるために、ActiveRecord::Baseが用意してくれた機能です。 カラムを削除、あるいはリネームしたい時、マイグレーションを実行する前に設定します。 ドキュメンテーションは2020/12月時点、上記リンクしか見つかりませんでした。(他にあったら教えてください🙏)

使い方はこんな感じです。

class Book < ApplicationRecord
  self.ignored_columns = %[author_name]
end

メソッドの中身はとてもシンプルです。(ソースコード

# Sets the columns names the model should ignore. Ignored columns won't have attribute
# accessors defined, and won't be referenced in SQL queries.
def ignored_columns=(columns)
  @ignored_columns = columns.map(&:to_s)
end

ignored_columnsはなぜ必要か

ActiveRecordはカラム情報をキャッシュしている

ActiveRecordモデルは、ActiveRecord::ModelSchema.load_schema!プライベートメソッドで、DBからカラム情報を読み込み、@columns_hashクラスインスタンス変数に保存しています。 (ignored_columnsexceptで除外していることがわかりますね。)

https://github.com/rails/rails/blob/5-2-stable/activerecord/lib/active_record/model_schema.rb#L465

def load_schema!
  @columns_hash = connection.schema_cache.columns_hash(table_name).except(*ignored_columns)
  @columns_hash.each do |name, column|
    define_attribute(
      name,
      connection.lookup_cast_type_from_column(column),
      default: column.default,
      user_provided_default: false
    )
  end
end

この@columns_hash変数の中身は.columns_hashメソッドで確認することができます。

# == Schema Information
#
# Table name: books
#
#  id          :bigint           not null, primary key
#  author_name :string(255)
#  title       :string(255)
#  created_at  :datetime         not null
#  updated_at  :datetime         not null
#
class Book < ApplicationRecord
end

例えば、上記のようなBookモデルがあった場合、.columns_hashメソッドの中身は以下のようになります。

[1] pry(main)> Book.columns_hash
=> {"id"=>
  #<ActiveRecord::ConnectionAdapters::MySQL::Column:0x000055bcf0cdbe48
   @collation=nil,
   @comment=nil,
   @default=nil,
   @default_function=nil,
   @name="id",
   @null=false,
   @sql_type_metadata=
    #<ActiveRecord::ConnectionAdapters::SqlTypeMetadata:0x000055bcf0cb00b8
     @limit=8,
     @precision=nil,
     @scale=nil,
     @sql_type="bigint(20)",
     @type=:integer>>,
 "title"=>
  #<ActiveRecord::ConnectionAdapters::MySQL::Column:0x000055bcf0cda5e8
   @collation="utf8mb4_general_ci",
   @comment=nil,
   @default=nil,
   @default_function=nil,
   @name="title",
   @null=true,
   @sql_type_metadata=
    #<ActiveRecord::ConnectionAdapters::SqlTypeMetadata:0x000055bcf0cda840
     @limit=255,
     @precision=nil,
     @scale=nil,
     @sql_type="varchar(255)",
     @type=:string>>,
  ...

キャッシュされたカラム情報はいつ更新されるのか

このキャッシュ情報は、明示的に更新(=ActiveRecord::ModelSchema.reset_column_informationメソッドを実行)しない限り、一度読み込まれたらずっと残ります。

https://github.com/rails/rails/blob/5-2-stable/activerecord/lib/active_record/model_schema.rb#L450

# ActiveRecord::ModelSchemaに定義されたプライベートクラスメソッドを一部抜粋

def schema_loaded?
  defined?(@schema_loaded) && @schema_loaded
end

def load_schema            # 他メソッドから呼び出されるのは`load_schema!`ではなくこっち
  return if schema_loaded? # カラム情報がすでに読み込まれていれば、更新しない
  @load_schema_monitor.synchronize do
    return if defined?(@columns_hash) && @columns_hash

    load_schema!

    @schema_loaded = true # 一度読み込んだら、読み込み済みフラグをtrueにする
  end
end

def load_schema!
  # 実際の読み込み処理
end

ignored_columnsを使わないでカラムを削除・リネームするととどうなるか?

ignored_columnsを使わずに、カラム削除のmigrationを含んだコードをデプロイした場合のことを考えます。 (ここではカラム削除について取り上げていますが、リネームについても同じことが言えます。)

  1. まず、migrationが実行され、DBのカラムが削除されまる
  2. 次に、新しいコードを動かしているサーバーに切り替わる

1.と2.の間の時間、各モデルの@columns_hash変数には、古いカラム情報が入ったままです。 この時の@columns_hashに実際のDBに存在しないカラムの情報が入っているという状況が原因で、エラーが発生する可能性があります。

ignored_columnsを使わずにカラムを削除・リネームすると問題が発生する具体的なコード例

※ あくまでいくつか例をあげたものであり、網羅的なリストではありません。

1. eager_loadを使っているコード

あるモデルを単体で使ってクエリする場合、発行されるSQLは以下のように*で全カラムを取得します。

-- Book.all
SELECT `books`.* FROM `books`

eager_loadを使うと、関連モデルとleft outer joinされて、発行されるSQLは以下のように、カラムを全て指定したものになります。

-- Book.eager_load(:author)
SELECT `books`.`id` AS t0_r0, `books`.`title` AS t0_r1, `books`.`author_name` AS t0_r2, `books`.`created_at` AS t0_r3, `books`.`updated_at` AS t0_r4, `books`.`author_id` AS t0_r5, `authors`.`id` AS t1_r0, `authors`.`name` AS t1_r1, `authors`.`nationality` AS t1_r2, `authors`.`created_at` AS t1_r3, `authors`.`updated_at` AS t1_r4 FROM `books` LEFT OUTER JOIN `authors` ON `authors`.`id` = `books`.`author_id`

このようにカラムを明示的に指定するSQLを生成するに当たってキャッシュされたカラム情報が使われます。 (参考: ActiveRecord::QueryMethods#build_select プライベートメソッド) なので、例えばignored_columnsを使わずにbooks.author_nameカラムを削除すると、DBのテーブルにカラムが存在しないのに上記SQLが発行されてしまい、Mysql2::Error: Unknown column 'books.author_name' in 'field list'みたいなエラーが発生して怒られます。

2. includesを使っているコードの一部

includesは内部的にeager_loadpreloadと同じ挙動を実現しています。

  • includesしたテーブルでwhereによる絞り込みを行っている
  • includesしたassociationに対してjoinsreferencesも呼んでいる
  • 任意のassociationに対してeager_loadも呼んでいる

のうちいずれかを満たす場合、eager_loadと同じ挙動(LEFT JOIN)を行い、 そうでなければpreloadと同じ挙動(クエリを分けて実行)をする。

@k0kubunさんの「ActiveRecordのjoinsとpreloadとincludesとeager_loadの違い」より引用)

includesを使った結果、eager_loadと同じ挙動になる場合、ignored_columnsが必要になります。

なので、例えば、Bookの関連モデルであるAuthorのカラムを使って絞り込んでいる場合、eager_loadと同じ挙動になり、以下のようなSQLが発行されます。

-- Book.includes(:author).where(authors: { nationality: 'us' }) 
SELECT `books`.`id` AS t0_r0, `books`.`title` AS t0_r1, `books`.`author_name` AS t0_r2, `books`.`created_at` AS t0_r3, `books`.`updated_at` AS t0_r4, `books`.`author_id` AS t0_r5, `authors`.`id` AS t1_r0, `authors`.`name` AS t1_r1, `authors`.`nationality` AS t1_r2, `authors`.`created_at` AS t1_r3, `authors`.`updated_at` AS t1_r4 FROM `books` LEFT OUTER JOIN `authors` ON `authors`.`id` = `books`.`author_id` WHERE `authors`.`nationality` = 'us'

そして、「1. eager_loadを使っているコード」と同じ理由でエラーが発生します。

3. その他、キャッシュされたカラム情報に依存しているメソッドを呼び出しているコード

キャッシュされたカラム情報に依存しているメソッドを全て列挙するのは現実的ではないのでしませんが、例えばActiveRecord::ModelSchemaモジュールに定義されている以下のメソッドなどが当てはまります。

  • .columns
  • .column_names
  • .attribute_types
  • .column_defaults

まとめ

  • ActiveRecordのモデルは、DBからカラム情報を読み取り、@columns_hash変数にキャッシュしている
  • migrationを実行してから新しいサーバーに切り替わるまで、DBの実際の状態と@columns_hashの情報にズレが生じて、それがエラーの原因になる
  • このズレを回避するために、カラムを削除・リネームする前にignored_columnsを設定し、@columns_hashに対象のカラムが含まれないようにする必要がある

ProgateAdventカレンダー次の記事はnakedtatsuyaさんです、お楽しみに!