自己紹介
株式会社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_columns
をexcept
で除外していることがわかりますね。)
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を含んだコードをデプロイした場合のことを考えます。 (ここではカラム削除について取り上げていますが、リネームについても同じことが言えます。)
- まず、migrationが実行され、DBのカラムが削除されまる
- 次に、新しいコードを動かしているサーバーに切り替わる
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_load
やpreload
と同じ挙動を実現しています。
includes
したテーブルでwhere
による絞り込みを行っているincludes
したassociationに対してjoins
かreferences
も呼んでいる- 任意の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さんです、お楽しみに!