💫

【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オブジェクトは:

  1. Book.newで作成された新しい Ruby オブジェクト
  2. 既存レコードの ID を持っているが、DB から読み込まれたものではない
  3. そのため、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_databasenilを返す
  • 実質的に、activerecord_importを使った一括更新ではattribute_in_databaseを使ったバリデーションは機能しない
  • 解決方法として、事前にfindでオブジェクトを取得するか、バリデーション内で直接 DB クエリを実行する必要があるが、これらはパフォーマンス上の問題を引き起こす
  • 大量データを扱う場合は、事前一括バリデーションやデータベース制約の活用を検討する

activerecord_importのようなバルク操作では、従来の ActiveRecord のバリデーション機能が期待通りに動作しないケースがあることを理解して設計することが重要です。

関連

https://api.rubyonrails.org/classes/ActiveRecord/AttributeMethods/Dirty.html
https://github.com/zdennis/activerecord-import

Sun* Developers

Discussion