バートリーのさいとうです。
今回は
update_allがupdated_atを更新してくれないから、activerecord-import gemを使うことになった
というテーマでお話ししていきます。
もし、こんな場面に遭遇したら皆さんならどう対応しますか?
「バリデーションの必要ない一括アップデートをしながら、更新日時も記録したい」
一見簡単そうでしたが、意外と奥が深い問いでした。
なぜなら、パッと思いつくActiveRecordのupdate_allメソッドだと、更新日時(updated_at)が更新されないからです。
こちらで議論がなされています。
ここで、updated_atを意図的に付与してあげないと更新してくれませんよ、と言っています。
You can update the
updated_at
value with it. But you have to provide it manually to the updated fields.
要は、「update_allは手動でupdated_atの時間をわざわざ入力しないと、更新してくれませんよ」、ということです。
これは面倒ですね…
そこで、今回解決策として、activerecord-import gemを利用した方法をご紹介します。
公式GIthub↓
この記事を読めば
- 少ないSQLでたくさんのデータを保存・更新できる方法が理解できる
- activerecord-import gemを利用し、複数レコードを更新する方法を理解できる
- update_allのように、いちいち更新日時を明示することなく、よしなにupdated_atを更新してくれる処理を書くことができるようになる。
と思いますので、実務で困っている方、一緒に課題を乗り越えていきましょう。
準備
では、公式の手順に沿ってgemを導入していきましょう。
先に環境をお示しします。
- rails 6.0.3.4
- ruby 2.6.5
bundlerを利用して、gemをインストールします。gemfileに以下のように記述します。
gem 'activerecord-import'
これで準備OKです。簡単ですね。
1つのレコードを更新する
では、まずは1つのレコードから更新していきましょう。
公式の例を参照します。
(※基本は同じですが、利用しているDBの種類によって処理の方法が多少異なります。今回はpostgresSQL verで話を進めます。)
title, authorカラムを持つBookモデルから、値を持つインスタンスを生成した後に、それぞれのカラムの値を代入します。
book = Book.create! title: "Book1", author: "George Orwell"
book.title = "Updated Book Title"
book.author = "Bob Barker"
その後、activerecord-import gemが提供しているimportメソッドで更新を行います。
Book.import [book], on_duplicate_key_update: {conflict_target: [:id], columns: [:title]}
# 簡略化した書き方(比較対象は自動的にprimary keyになります。)
Book.import [book], on_duplicate_key_update: [:title]
# 基本の型
モデル.import [更新したいインスタンス], on_duplicate_key_update: {conflict_target : [:比較するカラム名], columns: [:実際に更新するカラム名]}
2点ほど補足します。
- 更新したいカラム名を、columnsの引数として記述する必要がある。
- congflict_target, columnsの記述は省略可能(簡略化した書き方)。
コードをまとめると、こうなります。
book = Book.create! title: "Book1", author: "George Orwell"
book.title = "Updated Book Title"
book.author = "Bob Barker"
Book.import [book], on_duplicate_key_update: {conflict_target: [:id], columns: [:title]}
book.reload.title # => "Updated Book Title" (columnsに指定したので更新がかかる)
book.reload.author # => "George Orwell" (columnsに指定していないので、更新がかからない)
注意点としては、代入しただけではレコードは更新されないことです。理由は、SQLが生成されていないからです。
また、代入してもcolumsに指定しない場合は更新がされないので、更新したいカラムは記述する必要があります。
では、本題の複数レコードの一括更新で利用する例を見ていきましょう。
複数レコードを更新する
複数レコードを更新するのには、少しだけ工夫が必要です。
先ほどのbooksテーブルに以下のようなレコードが保存されているとします。
今回は、AuthortがAとCのTitleカラムを更新しようと思います。
まずは、実際のコードを見てください。
focus_books = Book.where(author: ["A", "C"])
update_books = []
focus_books.each do |focus_book|
focus_book.title = "更新しました"
update_books.push(focus_book)
end
Book.import update_books, on_duplicate_key_update: {conflict_target: [:id], columns: [:title]}
解説します。ポイントは
- focus_bookはwhere句で条件指定され、ActiveRecord::Relationの形で取得されているが、importメソッドではActiveRecord::Relationオブジェクトを更新できないため、配列の形に戻す必要がある。
ことです。詳しく説明します。
importメソッドは、ActiveRecord::Relationを更新できない
where句で条件を指定しレコードを取得すると、ActiveRecord::Relationオブジェクトで返ってきます。
この形のままだと、ArgumentErrorが発生し、更新ができません。
そこで、空の配列を用意し、ループで回して更新内容を代入して、その結果を用意した配列に入れるという処理を加えます。
# 更新する空の配列を用意する
update_books = []
# DBから取得したレコードをループし、titleカラムに値を代入して用意した配列に追加していく
focus_books.each do |focus_book|
focus_book.title = "更新しました"
update_books.push(focus_book)
end
こうして用意した配列を、importの引数に指定することで、更新ができるようになります。
Book.import update_books, on_duplicate_key_update: {conflict_target: [:id], columns: [:title]}
これで、一括更新ができるようになりました。
配列だと更新できる理由
ActiveRecord::Relationだと更新できないが、配列に入れ直せば更新できるのはなぜか?と思う方もいると思ったので、公式リファレンスを見てみたところ以下のようにありました。
The
https://github.com/zdennis/activerecord-import#columns-and-arraysimport
method can take an array of column names (string or symbols) and an array of arrays. Each child array represents an individual record and its list of values in the same order as the columns. This is the fastest import mechanism and also the most primitive.
要約すると
- 配列は受け入れているよ
- 配列一つ一つをDBに保存されているレコードとして見るように作られているよ
とのことです。こういうことができるということですね。
columns = [ :title, :author ]
values = [ ['Book1', 'George Orwell'], ['Book2', 'Bob Jones'] ]
Book.import columns, values
あまり難しく捉えず、まずはこんなことができるんだなぁくらいに思っておくと良いと思います。
おまけ importメソッドのことと、カスタマイズ性の高さ
今回は、非常に簡単な例で説明しましたが、バリデーションのことをあれこれできたりと、結構いろいろカスタマイズできるみたいです。
詳しくはこちらをご覧ください。
ちなみに、例で利用したon_duplicate_key_updateもオプションの一つです。upsertを可能にしてくれるオプションですね。
あと、今回やったことはbulk insertと呼ばれるそうです。
要は、一回の命令で(SQLで)DBに大量のデータの更新や保存(insert)を指示できる手法のことです。
実運用レベルの話にはなりますが、やはりパフォーマンスの高いサービスの方がサクサク動いて、気持ち良いですよね。
まとめ
いかがだったでしょうか。
今回は、update_allがupdated_atを更新してくれないから、activerecord-import gemを使うことになったというテーマでお話ししてきました。
SQLの呼び出し回数を意識した実装も更に意識していきたいなぁ。
このブログは、月に15本を目標に、実務から学んだプログラミングのあれこれを発信しています。
誰かのためになれば、嬉しいです。
最後まで読んでいただき、ありがとうございました。
それでは。