この記事は 第二のドワンゴ Advent Calendar 2019 の19日目の記事です。
誰
naari3です。
ドワンゴで N予備校 のバックエンド開発をやっています。
概要
Rails 5.0 から Rails 5.1 にアップデートする対応の最中に遭遇した現象を紹介します。
依存しているgem同士の相性が悪かった問題がRailsのアップデートによって解決されました。
内容的にはただのコードリーディングになってしまいますが、面白かったので書き起こすことにしました。
Activerecord-Import とは
ActiveRecord でバルクインサートをするためのgemです。
ActiveRecord::Base.import
(以下 AR.import
と記述します) が生えます。このメソッドにセーブしていないレコードの配列を渡す等することでSQLクエリを発行/実行し、バルクインサートを実現します。
Rails 5 以下で使用される ActiveRecord 単体では複数のレコードをバルクインサートする手段がないため、このgemを使うのが一般的かと思います。
- Rails 6 では ActiveRecord: に
#insert_all
が生えたのでこのgemを入れることもないのかなと思いましたが、 Activerecord-Import のほうが高機能ではあるので一部ケースではまだお世話になることがあるかもしれません。
DatabaseRewinder とは
前提として、テスト実行時、各テストケースごとにデータベースの各テーブルをきれいにする DatabaseCleaner というgemが存在します。
- rspec の場合、基本的には
after
のeach
のタイミングでテーブルの削除が実行されることが多いと思います。
これによって、例えば before
で作ったレコードが各テストケースに跨ってしまうのを防ぐことが出来ます。
DatabaseRewinder はテスト実行時に INSERT の対象になったテーブルを記憶することで削除を実行すべき対象を絞ることができ、 DatabaseCleaner と比べて高速に回すことが出来る、というコンセプトのgemです。便利
Rails 5.0 + DatabaseRewinder + Activerecord-Import + PostgreSQL + おおよそのテーブル という環境では動かない
それぞれ便利なgemなんですが、以下の環境だと AR.import
を実行しても対象テーブルが DatabaseRewinder の削除対象として認識してくれませんでした。
- Rails 5.0 (というかactiverecord 5.0)
- それ未満のRailsでも同じことが起こるかもしれないが未確認
- DatabaseRewinder (
<= 0.9.1
)- 現在の最新バージョン
- Activerecord-Import (
<= 1.0.3
)- 現在の最新バージョン
- PostgreSQL
- 対象テーブルにprimary keyが存在しない
- そんな環境ほとんどないと思います
- Activerecord-Importの
options[:no_returning]
が trueAR.import
の実行結果を返さないようにするオプション- デフォルトはfalse
- Activerecord-Importの
options[:recursive]
が false- has_many/has_oneに存在するモデルもバルクインサートするオプション
- デフォルトはfalse
上記の環境の場合、rspcの before
で AR.import
を使用して作成したレコードは次のテストケースにも存在していたりします。
なぜ動かなかったか
先程軽く説明した DatabaseRewinder ですが、ActiveRecordの特定メソッドが呼ばれた際に渡されたSQLのクエリを監視し、実際に INSERT が走ったかどうか確認することで削除実行対象のテーブルを記録しています。
ですが、先程記載した条件の場合、 AR.import
経由では特定のメソッドを実行しないため削除の対象になりません。
以下で DatabaseRewinder と Activerecord-Import の実際の動きを紹介しながら詳しく説明します。
DatabaseRewinder はどのようにクエリを監視するか
ここでは DatabaseRewinder がどのようにクエリを監視し、削除対象のテーブルを記録していくか を抜粋して紹介します。
::ActiveRecord::ConnectionAdapters::AbstractAdapter.execute
と ::ActiveRecord::ConnectionAdapters::AbstractAdapter.exec_query
をオーバーライドしています。このメソッドではクエリを DatabaseRewinder.record_inserted_table
に渡しています。
# https://github.com/amatsuda/database_rewinder/blob/v0.9.1/lib/database_rewinder/active_record_monkey.rb module DatabaseRewinder module InsertRecorder def execute(sql, *) DatabaseRewinder.record_inserted_table self, sql super end def exec_query(sql, *) DatabaseRewinder.record_inserted_table self, sql super end end end ::ActiveRecord::ConnectionAdapters::SQLite3Adapter.send :prepend, DatabaseRewinder::InsertRecorder if defined? ::ActiveRecord::ConnectionAdapters::SQLite3Adapter ::ActiveRecord::ConnectionAdapters::PostgreSQLAdapter.send :prepend, DatabaseRewinder::InsertRecorder if defined? ::ActiveRecord::ConnectionAdapters::PostgreSQLAdapter ::ActiveRecord::ConnectionAdapters::AbstractMysqlAdapter.send :prepend, DatabaseRewinder::InsertRecorder if defined? ::ActiveRecord::ConnectionAdapters::AbstractMysqlAdapter def (::ActiveRecord::ConnectionAdapters::AbstractAdapter).inherited(adapter) adapter.prepend DatabaseRewinder::InsertRecorder end
DatabaseRewinder.record_inserted_table
は次のような実装になっています。クエリが INSERT であれば対象のテーブル名を inserted_tables
に挿入し、削除対象となるテーブルを記録します。
# https://github.com/amatsuda/database_rewinder/blob/v0.9.1/lib/database_rewinder.rb#L37 def record_inserted_table(connection, sql) config = connection.instance_variable_get(:'@config') database = config[:database] #NOTE What's the best way to get the app dir besides Rails.root? I know Dir.pwd here might not be the right solution, but it should work in most cases... root_dir = defined?(Rails) && Rails.respond_to?(:root) ? Rails.root : Dir.pwd cleaner = cleaners.detect do |c| if (config[:adapter] == 'sqlite3') && (config[:database] != ':memory:') File.expand_path(c.db, root_dir) == File.expand_path(database, root_dir) else c.db == database end end or return sql.split(';').each do |statement| match = statement.match(/\A\s*INSERT(?:\s+IGNORE)?(?:\s+INTO)?\s+(?:\.*[`"]?([^.\s`"]+)[`"]?)*/i) next unless match table = match[1] if table cleaner.inserted_tables << table unless cleaner.inserted_tables.include? table cleaner.pool ||= connection.pool end end end
つまり、 INSERT のクエリが実行される際にメソッドのどこかで #exec_query
か #execute
が呼ばれることで削除対象のテーブルとして記録することが出来る、ということになります。
Activerecord-Import はどのように Rails のメソッドを呼ぶか
ここでは AR.import
を実行した後、どのように ActiveRecord のメソッドを実行していくか を抜粋して紹介します。
AR.import
を実行すると内部で #insert_many
メソッドを呼びます。 options[:no_returning]
(実行結果を返さない) はデフォルトがfalseなのでおおよその場合はelse句が実行されるはずです1。そのため、次に #select_rows
と #select_values
が実行されます。
# https://github.com/zdennis/activerecord-import/blob/v1.0.3/lib/activerecord-import/adapters/postgresql_adapter.rb#L7 def insert_many( sql, values, options = {}, *args ) # :nodoc: # 省略 if columns.blank? || (options[:no_returning] && !options[:recursive]) insert( sql2insert, *args ) else returned_values = if columns.size > 1 # Select composite columns select_rows( sql2insert, *args ) else select_values( sql2insert, *args ) end query_cache.clear if query_cache_enabled end # 省略 end
#select_rows
と #select_values
は(他にオーバーライドするgemが存在しなければ) Rails のメソッドになります。
これ以降のコードについては単純に追うだけで注目すべき箇所がないため折りたたみますが、要点としてはRails 5.0以下のpostgres用のアダプタを使う場合2、結局 #execute
や #exec_query
が呼ばれることはないということです。
ここをクリックすると展開されます
-- rails 5.0.7.2 のコード追跡ここから --
#select_rows
と #select_values
はどちらも #execute_and_clear
を呼び出します。
# https://github.com/rails/rails/blob/v5.0.7.2/activerecord/lib/active_record/connection_adapters/postgresql/database_statements.rb#L18 def select_values(arel, name = nil, binds = []) arel, binds = binds_from_relation arel, binds sql = to_sql(arel, binds) execute_and_clear(sql, name, binds) do |result| if result.nfields > 0 result.column_values(0) else [] end end end # Executes a SELECT query and returns an array of rows. Each row is an # array of field values. def select_rows(sql, name = nil, binds = []) execute_and_clear(sql, name, binds) do |result| result.values end end
#execute_and_clear
は #exec_no_cache
か #exec_cache
を呼び出します。
# https://github.com/rails/rails/blob/v5.0.7.2/activerecord/lib/active_record/connection_adapters/postgresql_adapter.rb#L585 def execute_and_clear(sql, name, binds, prepare: false) if without_prepared_statement?(binds) result = exec_no_cache(sql, name, []) elsif !prepare result = exec_no_cache(sql, name, binds) else result = exec_cache(sql, name, binds) end ret = yield result result.clear ret end
#exec_no_cache
と #exec_cache
については @connection
のメソッドを呼ぶ実装になっているのですが、ここから先は PostgreSQL のクラアントライブラリである pg のメソッドを呼び出すことになるので各自そちらを見てくさだい。
https://github.com/ged/ruby-pg/blob/v1.1.4/lib/pg/connection.rb#L271-L279
-- rails 5.0.7.2 のコード追跡ここまで --
ちなみに AR.create
の場合は #save
→ #create_or_update
→ #_create_record
→ #insert
→ #exec_insert
→ #exec_query
といった流れでメソッドが呼ばれていくため、特に問題なく DatabaseRewinder の削除実行の対象テーブルとして追加させることが出来ます。
なぜ動くようになったか
以上が Rails 5.0 では動かないという説明になるのですが、ここからは Rails 5.1 になってからうまく動くようになった、ということを説明します。
kamipoさんの一連のPR(下記参照)により、これまでpostgres用のアダプタにあるメソッドを呼ぶことでクエリを実行していたところ、abstractなアダプタにあるメソッドが呼ばれるようになったため、結果的に他のアダプタと同じように #exec_query
を呼ぶようになりました。
- 一連のPR
- https://github.com/rails/rails/pull/22973
- https://github.com/rails/rails/pull/24708
- https://github.com/Rails/Rails/pull/25522
- 以上それぞれ
#select_values
#select_rows
等を別のアダプタと同じようなインターフェイスとなるように変更
- 以上それぞれ
- https://github.com/rails/rails/pull/29454
- abstract の
#select_values
#select_rows
を呼ぶようになった
- abstract の
- abstract の
#select_values
#select_rows
は#select_values
→#select_rows
→#select_all
→ (#select
|#select_prepared
) →#exec_query
といった流れでメソッドが呼ばれる
以下は #exec_query
に到達するまでを追います。こちらも畳んでおきます。見たい人は見てください。
ここをクリックすると展開されます
-- rails 5.1.7 のコード追跡ここから --
#select_rows
と #select_values
はどちらも #select_all
を呼び出します。
# https://github.com/rails/rails/blob/v5.1.7/activerecord/lib/active_record/connection_adapters/abstract/database_statements.rb#L59-L67 def select_values(arel, name = nil, binds = []) select_rows(arel, name, binds).map(&:first) end # Returns an array of arrays containing the field values. # Order is the same as that returned by +columns+. def select_rows(arel, name = nil, binds = []) select_all(arel, name, binds).rows end
#select_all
は #select_prepared
か #select
を呼び出します。
# https://github.com/rails/rails/blob/v5.1.7/activerecord/lib/active_record/connection_adapters/abstract/database_statements.rb#L31 def select_all(arel, name = nil, binds = [], preparable: nil) arel, binds = binds_from_relation arel, binds sql = to_sql(arel, binds) if !prepared_statements || (arel.is_a?(String) && preparable.nil?) preparable = false else preparable = visitor.preparable end if prepared_statements && preparable select_prepared(sql, name, binds) else select(sql, name, binds) end end
#select_prepared
と #select
はどちらも #exec_query
を呼び出します。これは DatabaseRewinder がオーバーライドしたものなので期待した通りの動作をします 🎉🎉🎉
# https://github.com/rails/rails/blob/v5.1.7/activerecord/lib/active_record/connection_adapters/abstract/database_statements.rb#L370-L376 def select(sql, name = nil, binds = []) exec_query(sql, name, binds, prepare: false) end def select_prepared(sql, name = nil, binds = []) exec_query(sql, name, binds, prepare: true) end
-- rails 5.1.7 のコード追跡ここまで --
さいごに
なにかモンキーパッチを当てたい時、抽象的なクラスが用意されている場合、そこに実装されているメソッドを呼び出すことで対応を行いたいところです。
しかし、抽象的なクラスを継承したクラスは各所の問題に対応するために抽象的な実装から離れていってしまう、ということは往々にしてあることだと思います。
今回は kamipo さんの注力によって抽象的な実装を使うようになり、うまく動くようになった、というとても運が良い例の紹介でした。
Rails のアップデートによっては思いがけない副次的な恩恵を受けることもあるようです。随時追従していきましょう。
-
ここで
options[:no_returning] && !options[:recursive]
がtrueを満たすような場合、直接#insert
が実行されます。こちらは#insert
→#exec_insert
→#exec_query
といった流れでメソッドが呼ばれていくため、問題なく削除対象のテーブルとして追加させることが出来ます。↩ -
abstractなアダプタの
#select_rows
#select_values
はそれぞれ適切に#execute
や#exec_query
を呼ぶようになっています。さらに、それを継承したpostgres以外のRDB向けのアダプタはabstractなアダプタの#select_rows
#select_values
を呼び出すため問題なく動作します。↩