Rails 5.1 で DatabaseRewinder + Activerecord-Import + PostgreSQL が上手くいかない問題が期せずして(?)解決した

この記事は 第二のドワンゴ Advent Calendar 2019 の19日目の記事です。

qiita.com

naari3です。

ドワンゴで N予備校 のバックエンド開発をやっています。

概要

Rails 5.0 から Rails 5.1 にアップデートする対応の最中に遭遇した現象を紹介します。

依存しているgem同士の相性が悪かった問題がRailsのアップデートによって解決されました。

内容的にはただのコードリーディングになってしまいますが、面白かったので書き起こすことにしました。

Activerecord-Import とは

github.com

ActiveRecord でバルクインサートをするためのgemです。

ActiveRecord::Base.import (以下 AR.import と記述します) が生えます。このメソッドにセーブしていないレコードの配列を渡す等することでSQLクエリを発行/実行し、バルクインサートを実現します。

Rails 5 以下で使用される ActiveRecord 単体では複数のレコードをバルクインサートする手段がないため、このgemを使うのが一般的かと思います。

  • Rails 6 では ActiveRecord: に #insert_all が生えたのでこのgemを入れることもないのかなと思いましたが、 Activerecord-Import のほうが高機能ではあるので一部ケースではまだお世話になることがあるかもしれません。

DatabaseRewinder とは

github.com

前提として、テスト実行時、各テストケースごとにデータベースの各テーブルをきれいにする DatabaseCleaner というgemが存在します。

  • rspec の場合、基本的には aftereach のタイミングでテーブルの削除が実行されることが多いと思います。

これによって、例えば 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] が true
    • AR.import の実行結果を返さないようにするオプション
    • デフォルトはfalse
  • Activerecord-Importの options[:recursive] が false
    • has_many/has_oneに存在するモデルもバルクインサートするオプション
    • デフォルトはfalse

上記の環境の場合、rspcの beforeAR.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 を呼ぶようになりました。

以下は #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 のアップデートによっては思いがけない副次的な恩恵を受けることもあるようです。随時追従していきましょう。


  1. ここで options[:no_returning] && !options[:recursive] がtrueを満たすような場合、直接 #insert が実行されます。こちらは #insert#exec_insert#exec_query といった流れでメソッドが呼ばれていくため、問題なく削除対象のテーブルとして追加させることが出来ます。

  2. abstractなアダプタの #select_rows #select_values はそれぞれ適切に #execute#exec_query を呼ぶようになっています。さらに、それを継承したpostgres以外のRDB向けのアダプタはabstractなアダプタの #select_rows #select_values を呼び出すため問題なく動作します。