LoginSignup
42
33

More than 3 years have passed since last update.

gem activerecord-import の README を翻訳しました

Last updated at Posted at 2020-05-12

概要

gem activerecord-importREADME を翻訳しました。

Activerecord-Import

Activerecord-Import は ActiveRecord を使用してデータをバルクインサートするためのライブラリです。

このライブラリの主要な機能の1つは、下記の activerecord の関連付けで N+1 インサート問題を回避しつつ必要最低限の SQL インサート文を生成することです。具体例で説明していきます。下記のスキーマがあるとします。

  • 出版社は複数の本を持っている
  • 本は複数のレビューを持っている

そしてあなたは、本1つごとに3つのレビューがあり、出版社1つごとに1万の本を持つ100の新しい出版社をバルクインサートしたいと思っています。このライブラリは関連付けに従って、3つのみの SQL インサート文を生成するだけです。1つは出版社、1つは本、1つはレビューのインサート文です。

対照的に、標準的な ActiveRecord は出版社の100個のインサート文を生成し、それから各出版社のレコードを確認してすべての本を保存します。
100 * 10,000 = 1,000,000 SQL インサート文
そしてそれからレビューを保存します。
100 * 10,000 * 3 = 3M SQL インサート文

これは 4M対3の SQL インサート文となります。これにより非常に大きなパフォーマンスの改善となります。今回の例では、18時間のバッチ処理時間が2時間未満になります。

この gem は下記の高水準の機能を提供します。

  • ローカラムと値の配列を伴った動作(最も速い)
  • モデルオブジェクトを伴った動作(より速い)
  • バリデーションの実行(速い)
  • duplicate key updates の実行(MySQL, SQLite 3.24.0+, Postgres 9.5+を必要とします)

目次

導入

この gem は import メソッドを ActiveRecord クラスに追加します。
あるいは elasticsearch-modelのような gem との互換性のために bulk_import メソッドを追加します。Conflicts With Other Gemsをご覧ください

activerecord-import を使用しない場合、下記のような記述をするでしょう。

10.times do |i|
  Book.create! name: "book #{i}"
end

このコードは10回の SQL を呼び出すことになります。activerecord-import を使用すると、代わりに下記のように書くことができます。

books = []
10.times do |i|
  books << Book.new(name: "book #{i}")
end
Book.import books    # あるいは import! を使用してください

1回の SQL 呼び出しのみが起こります。

カラムと配列

import メソッドはカラム名(string or symbols)の配列と配列の配列を受けることができます。各子配列は個々のレコードとカラムと同順の値のリストをあらわしています。これは最も速いインポートの仕組みであり、もっとも原始的な仕組みでもあります。

columns = [ :title, :author ]
values = [ ['Book1', 'George Orwell'], ['Book2', 'Bob Jones'] ]

# モデルのバリデーションなしのインポート
Book.import columns, values, validate: false

# モデルのバリデーション有りのインポート
Book.import columns, values, validate: true

# 指定がない場合、:validate はデフォルトで true になる
Book.import columns, values

ハッシュ

import メソッドはハッシュの配列を受けることができます。キー名はデータベースのカラム名に対応しています。

values = [{ title: 'Book1', author: 'George Orwell' }, { title: 'Book2', author: 'Bob Jones'}]

# モデルのバリデーションなしのインポート
Book.import values, validate: false

# モデルのバリデーション有りのインポート
Book.import values, validate: true

# 指定がない場合、:validate はデフォルトで true になる
Book.import values

ハッシュと明示的なカラム名を使用したインポート

import メソッドはカラム名の配列とハッシュオブジェクトの配列を受け取ることができます。カラム名はどのデータフィールドがインポートされるかを決めるために使用されます。下記の例では title フィールドのみインポートされます。

books = [
  { title: "Book 1", author: "George Orwell" },
  { title: "Book 2", author: "Bob Jones" }
]
columns = [ :title ]

# バリデーションなし
Book.import columns, books, validate: false

# バリデーション有り
Book.import columns, books, validate: true

# 指定がない場合、:validate はデフォルトで true になる
Book.import columns, books

# books テーブルの結果
# title  | author
#--------|--------
# Book 1 | NULL
# Book 2 | NULL

配列の各ハッシュのカラムが一致している場合のみ、ハッシュの使用は動作します。そうでない場合は例外が発生します。2つの回避策があります。1つ目は、ActiveRecord オブジェクトの配列をインスタンス化した配列を使用し、それを import メソッドに渡す方法です。2つ目は、配列が一致したカラムを持つ複数の配列に分割し、別々に import メソッドに渡す方法です。

詳細は https://github.com/zdennis/activerecord-import/issues/507 をご覧ください。

arr = [
  { bar: 'abc' },
  { baz: 'xyz' },
  { bar: '123', baz: '456' }
]

# 例外が発生する
Foo.import arr

# better
arr.map! { |args| Foo.new(args) }
Foo.import arr

# better
arr.group_by(&:keys).each_value do |v|
 Foo.import v
end

ActiveRecord モデル

import メソッドはモデルの配列を受け取ることができます。モデルの利用可能なカラムを探すことで各モデルから属性を取得します。

books = [
  Book.new(title: "Book 1", author: "George Orwell"),
  Book.new(title: "Book 2", author: "Bob Jones")
]

# バリデーションなし
Book.import books, validate: false

# バリデーション有り
Book.import books, validate: true

# 指定がない場合、:validate はデフォルトで true になる
Book.import books

import メソッドはカラム名の配列とモデルの配列を受け取ることができます。カラム名はどのデータフィールドがインポートされるかを決めるために使用されます。下記の例では title フィールドのみインポートされます。

books = [
  Book.new(title: "Book 1", author: "George Orwell"),
  Book.new(title: "Book 2", author: "Bob Jones")
]
columns = [ :title ]

# バリデーションなし
Book.import columns, books, validate: false

# バリデーション有り
Book.import columns, books, validate: true

# 指定がない場合、:validate はデフォルトで true になる
Book.import columns, books

# books テーブルの結果
# title  | author
#--------|--------
# Book 1 | NULL
# Book 2 | NULL

バッチ

import メソッドはインサート文ごとにインサートする行数を制御するために、batch_size オプションを受け取ることができます。デフォルトはインサートされる全レコードになっているので、1つのインサート文が発行されます。

books = [
  Book.new(title: "Book 1", author: "George Orwell"),
  Book.new(title: "Book 2", author: "Bob Jones"),
  Book.new(title: "Book 1", author: "John Doe"),
  Book.new(title: "Book 2", author: "Richard Wright")
]
columns = [ :title ]

# 4レコードで2つのインサート文が発行される
Book.import columns, books, batch_size: 2

再帰

注意: この機能は PostgreSQL のみで動作します。

本は複数のレビューを持つと仮定します。

books = []
10.times do |i|
  book = Book.new(name: "book #{i}")
  book.reviews.build(title: "Excellent")
  books << book
end
Book.import books, recursive: true

オプション

Key Options Default Description
:validate true/false true ActiveRecord バリデーション(ユニークネスはスキップされる)の実行の有無。import! メソッドの使用時にはこのオプションは常に true になります。
:validate_uniqueness true/false false ユニークネスバリデーションの実行の有無。潜在的な落とし穴がある可能性があります。バージョン >= v0.27.0 を必要とするので注意して使用してください。
:validate_with_context Symbol :create/:update 各モデルに ActiveModel バリデーションコンテキストを渡すことを許可します。デフォルは、新レコードに対しては :create、既存レコードに対しては :update です。
:on_duplicate_key_ignore true/false false 重複したキーのレコードをスキップすることを許可します。詳細はこちらをご覧ください。
:ignore true/false false :on_duplicate_key_ignore のエイリアスです。
:on_duplicate_key_update :all, Array, Hash N/A upsert を許可します。詳細はこちらをご覧ください。
:synchronize Array N/A ActiveRecord のインスタンスの配列が入ります。メモリ上の既存のインスタンスにインポートによる更新をシンクロします。
:timestamps true/false true インポートするレコードにおけるタイムスタンプの有効・無効。
:recursive true/false false has_many/has_one の関連付けをインポートします(PostgreSQLのみ)。
:batch_size Integer total # レコードの インポートごとにインサートするレコードの最大数。
:raise_error true/false false 最初の無効なレコードで例外を起こします。インポートの返り値でオブジェクトは返しません。import! メソッドのショートカットです。
:all_or_none true/false false ある1つのレコードでバリデーションエラーが起こったときにすべてのインポートを無効にするかどうか。

Duplicate Key Ignore

MySQL, SQLitePostgreSQL (9.5+) は主キーあるいはユニークキー制約に引っかかったときにそのレコードをスキップする on_duplicate_key_ignore をサポートしています。

on_duplicate_key_ignore は Postgres 9.5+ では ON CONFLICT DO NOTHING、MySQL では INSERT IGNORE、SQLite では INSERT OR IGNORE を追加します。再帰的インポートではこのオプションは利用できません。データベースアダプタはインポートされるオブジェクトの主キーの設定を標準的にサポートするため、このオプションはそれを妨げます。

book = Book.create! title: "Book1", author: "George Orwell"
book.title = "Updated Book Title"
book.author = "Bob Barker"

Book.import [book], on_duplicate_key_ignore: true

book.reload.title  # => "Book1"     (変化なし)
book.reload.author # => "George Orwell" (変化なし)

PostgreSQL のインポートにおいて :recursive オプションが利用可能なとき、:on_duplicate_key_ignore オプションは無視されます。

Duplicate Key Update

MySQL, PostgreSQL (9.5+), SQLite (3.24.0+) は主キーあるいはユニークキー制約に該当したときのみ更新されるフィールドを指定する on duplicate key update("upsert"としても知られています) をサポートします。

MySQL と PostgreSQL では一つの大きな差異があります。それは MySQL は発生したコンフリクトすべてを処理しますが、PostgreSQL はコンフリクトが発生するカラムが指定されたものであることを必要とします。SQLite は PostgreSQL の動作に倣います。

下記は MySQL の ON DUPLICATE KEY UPDATE、Postgres/SQLite の ON CONFLICT DO UPDATE を使用した upsert の記述です。

基本的な更新

book = Book.create! title: "Book1", author: "George Orwell"
book.title = "Updated Book Title"
book.author = "Bob Barker"

# MySQL バージョン
Book.import [book], on_duplicate_key_update: [:title]

# PostgreSQL バージョン
Book.import [book], on_duplicate_key_update: {conflict_target: [:id], columns: [:title]}

# PostgreSQL 省略バージョン (conflict_target は主キーである必要があります)
Book.import [book], on_duplicate_key_update: [:title]

book.reload.title  # => "Updated Book Title" (変更)
book.reload.author # => "George Orwell"      (変更なし)

別カラムの値を使用

book = Book.create! title: "Book1", author: "George Orwell"
book.title = "Updated Book Title"

# MySQL バージョン
Book.import [book], on_duplicate_key_update: {author: :title}

# PostgreSQL バージョン (非省略バージョン)
Book.import [book], on_duplicate_key_update: {
  conflict_target: [:id], columns: {author: :title}
}

book.reload.title  # => "Book1"              (変更なし)
book.reload.author # => "Updated Book Title" (変更)

カスタム SQL を使用

book = Book.create! title: "Book1", author: "George Orwell"
book.author = "Bob Barker"

# MySQL バージョン
Book.import [book], on_duplicate_key_update: "author = values(author)"

# PostgreSQL バージョン
Book.import [book], on_duplicate_key_update: {
  conflict_target: [:id], columns: "author = excluded.author"
}

# PostgreSQL 省略バージョン(conflict_target は主キーである必要があります)
Book.import [book], on_duplicate_key_update: "author = excluded.author"

book.reload.title  # => "Book1"      (変更なし)
book.reload.author # => "Bob Barker" (変更)

制約を使用した PostgreSQL

book = Book.create! title: "Book1", author: "George Orwell", edition: 3, published_at: nil
book.published_at = Time.now

# マイグレーション
execute <<-SQL
      ALTER TABLE books
        ADD CONSTRAINT for_upsert UNIQUE (title, author, edition);
    SQL

# PostgreSQL バージョン
Book.import [book], on_duplicate_key_update: {constraint_name: :for_upsert, columns: [:published_at]}


book.reload.title  # => "Book1"          (変更なし)
book.reload.author # => "George Orwell"      (変更なし)
book.reload.edition # => 3               (変更なし)
book.reload.published_at # => 2017-10-09 (変更)
Book.import books, validate_uniqueness: true

返り値

import メソッドは failed_instancesnum_inserts に応答する Result オブジェクトを返却します。加えて Postgres の場合は、idsresults という2つの配列も利用可能です。

articles = [
  Article.new(author_id: 1, title: 'First Article', content: 'This is the first article'),
  Article.new(author_id: 2, title: 'Second Article', content: ''),
  Article.new(author_id: 3, content: '')
]

demo = Article.import(articles, returning: :title) # => #<struct ActiveRecord::Import::Result

demo.failed_instances
=> [#<Article id: 3, author_id: 3, title: nil, content: "", created_at: nil, updated_at: nil>]

demo.num_inserts
=> 1,

demo.ids
=> ["1", "2"] # Postgres のとき
=> [] # 他 DB のとき

demo.results
=> ["First Article", "Second Article"] # Postgres のとき
=> [] # 他 DB のとき

カウンターキャッシュ

import を実行したとき、activerecord-import はカウンターキャッシュを自動的に更新しません。それらのカラムを更新するには、下記のどちらかを行う必要があります。

  • import メソッドに渡されるオブジェクトの引数としてカウンターキャッシュカラムに値を与える
  • レコードのインポート後にカウンターキャッシュカラムを手動で更新する

ActiveRecord のタイムスタンプ

ActiveRecord に慣れている場合、created_at, created_on, updated_at, updated_on などのタイムスタンプカラムがに馴染みがあると思います。データをインポートするとき、このタイムスタンプフィールドは期待通りに動作し、タイムスタンプカラムに値がセットされます。

これらのカラムに値を指定したいときは、timestamps: false オプションを使用してください。

しかしながら、特定のレコードにおいて :created_at のみを設定することもできます。この場合、timestamps: true を使用するにも関わらず、フィールドが nil のレコードにおいて :created_at のみが更新されます。recursive: true オプションを使用するときにも関連付けられたレコードに対して同じルールが適用されます。

カスタムタイムゾーンを使用している場合は、 ActiveRecord::Base.default_timezone が設定されているとき(ほぼすべての Rails アプリがこの設定である)と同様にインポートを行う際にそれらも考慮されます。

コールバック

import メソッドを呼び出したとき、レコードの 作成, 更新, 削除before_validationafter_validation ではなく)に関連する ActiveRecord のコールバックは呼び出されません。import メソッドでは大量のデータ行のインポートが行われ、かつ、メモリ内の ActiveRecord オブジェクトにアクセスする必要が必ずしもないからです。

メモリ内に ActiveRecord オブジェクトのコレクションを持っている場合は、下記のように記述することができます。

books.each do |book|
  book.run_callbacks(:save) { false }
  book.run_callbacks(:create) { false }
end
Book.import(books)

上記のコードは各アイテムで before_create と before_save コールバックを実行します。引数 false は after_save コールバックが実行されないようにするのに必要ですが、これはバルクインポートには意味をなしません。上記のコードでは before_create と before_save コールバックはバリデーションコールバックの前に実行されることに注意してください。

これが課題になる場合は、別の方法として、モデルをループさせてバリデーションを行い、バリデーションが問題ないモデルのみ run_callbacks を実行してインポートすることもできます。

valid_books = []
invalid_books = []

books.each do |book|
  if book.valid?
    valid_books << book
  else
    invalid_books << book
  end
end

valid_books.each do |book|
  book.run_callbacks(:save) { false }
  book.run_callbacks(:create) { false }
end

Book.import valid_books, validate: false

サポートされているアダプタ

下記のデータベースアダプタが現在サポートされています。

  • MySQL - 中心的な重要機能に加えて duplicate key update もサポートしています(activerecord-import 0.1.0 以上に含まれています)
  • MySQL2 - 中心的な重要機能に加えて duplicate key update もサポートしています(activerecord-import 0.2.0 以上に含まれています)
  • PostgreSQL - 中心的な重要機能をサポートしています(activerecord-import 0.1.0 以上に含まれています)
  • SQLite3 - 中心的な重要機能をサポートしています(activerecord-import 0.1.0 以上に含まれています)
  • Oracle - DML トリガーを通して中心的な重要機能をサポートしています(外部 gem activerecord-import-oracle_enhancedで利用可能です)
  • SQL Server - 中心的な重要機能をサポートしています(外部 gem activerecord-import-sqlserverで利用可能です)

使用しているアダプタがリストにないときは、README に記載されているように外部 gem を作成することを考慮に入れることをお願いします。もし外部 gem を作成した場合は、wiki を更新して新しいアダプタのリポジトリへのリンクを含めてください。

使用しているアダプタがどの機能をサポートしているかを知るためには、下記のモデルクラスのメソッドを使用してください。
* supports_import?(*args)
* supports_on_duplicate_key_update?
* supports_setting_primary_key_of_imported_objects?

追加のアダプタ

activerecord-import(それに続いて activerecord) によって動的に読み込まれるアダプタのために、命名規約の仕組みに一致するアダプタを提供することによって、追加のアダプタは外部 gem によって activerecord-import に与えられます。このことはロードパスにフォルダを与えることを必要とします。ロードパスは activerecord-import の命名規約に従い、activerecord-import が動的にファイルを読み込めるようにします。

ActiveRecord::Import.require_adapter("fake_name") が呼び出されるとき、下記を require してください。

require 'activerecord-import/active_record/adapters/fake_name_adapter'

これによって、外部 gem が activerecord-import gem の中核に何らかのファイル・コードを追加することなくアダプタを動的に追加できるようになります。

Requiring

注意: これらの説明は 0.2.0 以上のバージョンを使用しているときのみ動作します。

Bundler による自動読み込み

Rails を使用している、あるいは Bundler によって依存関係をオートロードしている場合は、下記のように Gemfile に gem を追加するだけです。

gem 'activerecord-import'

手動読み込み

何らかの理由で、手動で activerecord-import を読み込みたい場合があると思います。
その際はまず引数 require: false を追加してください。

gem 'activerecord-import', require: false

これにより、必要なファイル内で必要な部分だけ activerecord-import を読み込めるようになります。
Rails 内で手動読み込みを行い、ActiveRecord がデータベース接続をしている場合は(コントローラー内のように)、特別な初期化処理を行う必要があります。

require 'activerecord-import/base'
# 適切なデータベースアダプタを読み込んでください(postgresql, mysql2, sqlite3 など)
require 'activerecord-import/active_record/adapters/postgresql_adapter'

gem の依存関係がオートロードではなく、スクリプトがデータベースコネクションを作成している場合は、単純に ActiveRecord が読み込まれたあとに activerecord-import を require してください。

require 'active_record'
require 'activerecord-import'

ロードパスの仕組み

rubygems がどのようにコードを読み込んでいるかを理解するためには、下記を参照してください。

active_record が動的にアダプタを読み込む例がこちらです。
https://github.com/rails/rails/blob/master/activerecord/lib/active_record/connection_adapters/connection_specification.rb

まとめると、gem が読み込まれたとき、rubygems はその gem の lib フォルダをグローバルロードパスの $LOAD_PATH に追加します。それによって、すべての require の検索がロードパス上の全フォルダを探索しなくてすみます。require が実行されたとき、$LOAD_PATH の各フォルダはファイル/フォルダの参照のために確認されます。これによって、gem(activerecord-import のような)は $LOAD_PATH に activerecord-import フォルダ(あるいはネームスペース)を追加できるように定義できるようになり、require が実行されたときに、activerecord-import によって提供されるアダプタを発見できるようになります。

もし gem(潜在的に activerecord-import-fake_name と呼ばれる) に fake_name アダプタが必要になった場合、フォルダ構造は下記のようになります。

activerecord-import-fake_name/
|-- activerecord-import-fake_name.gemspec
|-- lib
|   |-- activerecord-import-fake_name.rb
|   |-- activerecord-import-fake_name
|   |   |-- version.rb
|   |-- activerecord-import
|   |   |-- active_record
|   |   |   |-- adapters
|   |   |       |-- fake_name_adapter.rb

rubygems が lib フォルダをロードパスに追加したとき、$LOAD_PATH 内のパスの下で ruby ファイルの探索プロセスを通して起動するので、requireactiverecord-import/active_record/adapters/fake_name_adapter を探します。

他の gem との衝突

Activerecord-Import は ActiveRecord::Base.import メソッドを追加します。elasticsearch-rails のように同じ名前のメソッドを追加する他の gem も存在します。このような衝突のために、代わりに使用可能な .bulk_import というエイリアスメソッドが存在します。

apartment gem を使用している場合、モデルの sequence_name のキャッシュに関わって apartment,activerecord-import,activerecord 間に奇妙な3重の相互作用があります。モデル内でこの値を明確に設定することで、対応することができます。

class Post < ActiveRecord::Base
  self.sequence_name = "posts_seq"
end

この問題に対応する別のやり方として、モデルで .reset_sequence_name を呼び出すこともできます。

schemas.all.each do |schema|
  Apartment::Tenant.switch! schema.name
  ActiveRecord::Base.transaction do
    Post.reset_sequence_name

    Post.import posts
  end
end

詳細な議論は https://github.com/zdennis/activerecord-import/issues/233 をご覧ください。

詳細情報

Activerecord-Import の詳細な情報については、下記の wiki を参照してください。
https://github.com/zdennis/activerecord-import/wiki

新しい情報を記述するときは、 wiki の代わりに README に追加してください。
それについての議論は下記をご覧ください。
https://github.com/zdennis/activerecord-import/issues/397 for discussion.

Contributing

Running Tests

The first thing you need to do is set up your database(s):

  • copy test/database.yml.sample to test/database.yml
  • modify test/database.yml for your database settings
  • create databases as needed

After that, you can run the tests. They run against multiple tests and ActiveRecord versions.

This is one example of how to run the tests:

rm Gemfile.lock
AR_VERSION=4.2 bundle install
AR_VERSION=4.2 bundle exec rake test:postgresql test:sqlite3 test:mysql2

Once you have pushed up your changes, you can find your CI results here.

Issue Triage

You can triage issues which may include reproducing bug reports or asking for vital information, such as version numbers or reproduction instructions. If you would like to start triaging issues, one easy way to get started is to subscribe to activerecord-import on CodeTriage.

License

This is licensed under the ruby license.

Author

Zach Dennis (zach.dennis@gmail.com)

42
33
0

Register as a new user and use Qiita more conveniently

  1. You get articles that match your needs
  2. You can efficiently read back useful information
  3. You can use dark theme
What you can do with signing up
42
33