【Rails】activerecord_importでattribute_in_databaseが期待通りに動作しない理由と対処法
はじめに
attribute_in_database
は、データベースの値を取得する Dirty メソッドです。
本記事では、activerecord_import
を使用してバルク更新を行う際に、attribute_in_database
を使用して実装したバリデーションが想定通りに動作しなかった現象と原因、解決法をまとめます。
現象: attribute_in_database で DB の値が取得できない
例えば、図書館の本の所在地を管理するシステムを考えてみます。
本(Book
)は図書館(Library
)に所蔵されており、図書館はどの県に立地しているかの情報(Prefecture
)を持つとします。
そして、本は別の県の図書館へ移動できないというバリデーションを実装しました。
# 間違った実装
class Book
validate :cannot_change_other_prefecture_library, on: :update
def cannot_change_other_prefecture_library
# 現在(DB上)の図書館IDを取得
before_library_id = attribute_in_database(:library_id)
# attribute_in_databaseがnilの場合はスキップ(新規レコードの場合)
return if before_library_id.nil?
# 現在(DB上)の図書館
before_library = Library.find(before_library_id)
# 現在(DB上)の図書館の県のID
before_prefecture_id = before_library.prefecture_id
# 現在(DB上)の図書館の県 と 移動先の図書館の県 が異なる場合, エラーを追加
if before_prefecture_id != library.prefecture_id
errors.add(:book, '別の県の図書館へは移動できません')
end
end
end
そして、わざとバリデーションが失敗するように別の県の図書館に移動してみます。
activerecord_import
でタイトルが一致したものを更新します。
# 現在の図書館
before_prefecture = Prefecture.create(name: '新潟')
before_library = Library.create(name: '新潟図書館', prefecture: before_prefecture)
# 移動先の図書館
after_prefecture = Prefecture.create(name: '東京')
after_library = Library.create(name: '東京図書館', prefecture: after_prefecture)
# 既存の本を作成
existing_book = Book.create(title: '星の王子さま', library: before_library)
# newで新しいオブジェクトを作成(既存レコードの更新用)
book = Book.new(id: existing_book.id, title: '星の王子さま', library: after_library)
Book.import [book], on_duplicate_key_update: [:library_id], validate: true
この時、import はエラーなく実行されてしまいました。本来であれば、バリデーションが失敗して更新が拒否されるべきですが、実際には別の県への移動が成功してしまいます。
調べてみると、バリデーション内でattribute_in_database(:library_id)
がnil
を返していることがわかりました。
原因: new されたオブジェクトは DB に永続化されていない
attribute_in_database
は、データベースから読み込まれた(永続化済み)オブジェクトでのみ正しく動作します。
今回の場合、activerecord_import
に渡しているbook
オブジェクトは:
-
Book.new
で作成された新しい Ruby オブジェクト - 既存レコードの ID を持っているが、DB から読み込まれたものではない
- そのため、
attribute_in_database
は DB の値を参照できない
このため、attribute_in_database(:library_id)
を呼び出しても、DB の値ではなくnil
が返されてしまいます。
# このオブジェクトはnewで作成されているため、attribute_in_databaseが機能しない
book = Book.new(id: existing_book.id, title: '星の王子さま', library: after_library)
book.attribute_in_database(:library_id) # => nil (期待値: before_library.id)
重要なポイント: attribute_in_database
は、ID をセットしていてもnew
で作成されたオブジェクトでは常にnil
を返します。これは実運用でも遭遇しやすい紛らわしい挙動です。
解決方法を考える: 事前に find でオブジェクトを取得する?
事前にfind
でオブジェクトを取得してから属性を変更すれば、activerecord_import
でもバリデーションは正しく動作します。
book = Book.find_by(title: '星の王子さま')
book.library = after_library
Book.import [book], on_duplicate_key_update: [:library_id], validate: true
しかし、この方法では、更新対象の件数分だけfind_by
のクエリが発生します。activerecord_import
でバルクインサート・更新のパフォーマンス向上を狙っているのに、事前の取得で個別クエリが必要になるため、件数が多い場合はパフォーマンス上の問題となる可能性があります。
解決方法を考える: パフォーマンスを考慮した実装方法
find
を使用した解決方法は個別クエリが発生するため、大量データの場合はパフォーマンス上の問題があります。以下のような代替案が考えられます:
方法 1: 図書館ごとにグループ化してインサート
books_by_library = books.group_by(&:library_id)
books_by_library.each do |library_id, grouped_books|
Book.import grouped_books, on_duplicate_key_update: [:library_id], validate: true
end
方法 2: バリデーションを事前に一括実行
私のプロジェクトではこちらの方法で対応しました。
# 1. 更新対象の既存データを一括取得
existing_books = Book.where(id: books.map(&:id)).includes(library: :prefecture)
existing_data = existing_books.index_by(&:id)
# 2. バリデーションを事前チェック
invalid_books = books.select do |book|
existing_book = existing_data[book.id]
existing_book&.library&.prefecture_id != book.library.prefecture_id
end
# 3. バリデーションエラーがなければ一括更新
if invalid_books.empty?
Book.import books, on_duplicate_key_update: [:library_id], validate: false
else
# エラー処理
end
方法 3: データベース制約で対応
# マイグレーションでチェック制約を追加
add_check_constraint :books,
"prefecture_id_unchanged(library_id, OLD.library_id)",
name: "books_prefecture_check"
大量データを扱う場合は、アプリケーションレベルのバリデーションよりもデータベース制約やストアドプロシージャーの活用も検討する価値があります。
まとめ
-
attribute_in_database
は、DB に永続化されているオブジェクトでのみ正しく動作する -
activerecord_import
で新規作成されたオブジェクトではattribute_in_database
はnil
を返す - 実質的に、
activerecord_import
を使った一括更新ではattribute_in_database
を使ったバリデーションは機能しない - 解決方法として、事前に
find
でオブジェクトを取得するか、バリデーション内で直接 DB クエリを実行する必要があるが、これらはパフォーマンス上の問題を引き起こす - 大量データを扱う場合は、事前一括バリデーションやデータベース制約の活用を検討する
activerecord_import
のようなバルク操作では、従来の ActiveRecord のバリデーション機能が期待通りに動作しないケースがあることを理解して設計することが重要です。
関連
Discussion