😏

Rails のコールバックでデフォルト値を入れるのに失敗した話

2024/11/26に公開

ラブグラフでCTOやエンジニアをしております横江( @yokoe24 )です!
ハマりがちな Rails 独特の挙動について紹介します。

前提情報(テーブル&モデル)

店舗からのフードデリバリーを管理する Web アプリ で、
以下のようなテーブルやモデルがあったとします。

テーブル

  • food_deliveries: 配達1件あたりの情報
  • foods: 配達する食品の情報
  • food_delivery_foods: 配達1件でお届けする食品一覧を格納する中間テーブル

(以下は ridgepoleSchemafile.rb を想定)

# 配達1件あたりの情報
create_table :food_deliveries do |t|
  t.integer :delivery_minutes, null: false, default: 60 # お届けにかかる時間(分)
end

# 配達1件でお届けする商品を管理する food_deliveries と foods の中間テーブル
create_table :food_delivery_foods, id: false do |t|
  t.bigint :food_delivery_id, null: false
  t.bigint :food_id,          null: false

  t.index :food_delivery_id
  t.index :food_id
  t.index [:food_delivery_id, :food_id], unique: true
end

add_foreign_key :food_delivery_foods, :food_deliveries
add_foreign_key :food_delivery_foods, :foods

# 配達する食品の情報
create_table :foods do |t|
  t.string  :name,            null: false              # 商品名
  t.integer :cooking_minutes, null: false, default: 30 # 調理時間(分)
end

モデル

そして、 FoodDelivery モデルは以下のようになっているとします。

# 配達1件あたりの情報を管理
class FoodDelivery < ApplicationRecord
  has_many :food_delivery_foods, dependent: :destroy
  has_many :foods, through: :food_delivery_foods

  validates :delivery_minutes, presence: true

  before_validation :set_delivery_minutes, on: :create

  private

  def set_delivery_minutes
    # 配達する商品の中でもっとも長い cooking_minutes + 30 を設定
    # 保存時に delivery_minutes を指定しているならその値を使いたいので ||=
    self.delivery_minutes ||= self.foods.map(&:cooking_minutes).max + 30
  end
end

food_delivery.save! で何が起こる?

さて、以下のように food_delivery.save! をおこなったとき、
food_delivery.delivery_minutes の値はどうなるでしょうか?

> food_delivery = FoodDelivery.new

> food_delivery.foods << Food.where(id: 1..2)
=> [
  #<Food:0x0000ffff76da09c8 id: 1, name: "カレー", cooking_minutes: 20>,
  #<Food:0x0000ffff76d76d30 id: 2, name: "焼肉弁当", cooking_minutes: 40>
]

> food_delivery.save!

FoodDelivery クラスには before_validation :set_delivery_minutes, on: :create のコールバックが定義されていて、
set_delivery_minutes メソッドは以下のような定義でしたね。

def set_delivery_minutes
  # 配達する商品の中でもっとも長い cooking_minutes + 30 を設定
  self.delivery_minutes ||= self.foods.map(&:cooking_minutes).max + 30
end

そうなってくると、 food_delivery.foods には
cooking_minutes: 20 のカレーと cooking_minutes: 40 の焼肉弁当が登録されていて、
もっとも長い cooking_minutes は焼肉弁当の40。

それに 30 を足した 70 が delivery_minutes の値になりそうです!

> food_delivery.foods << Food.where(id: 1..2)
=> [
  #<Food:0x0000ffff76da09c8 id: 1, name: "カレー", cooking_minutes: 20>,
  #<Food:0x0000ffff76d76d30 id: 2, name: "焼肉弁当", cooking_minutes: 40>
]

> food_delivery.foods.map(&:cooking_minutes).max + 30
=> 70

ところが、実際に保存をしてみると

> food_delivery.save!
  TRANSACTION (0.7ms)  BEGIN
  FoodDelivery Create (3.6ms)  INSERT INTO `food_deliveries` VALUES ()
  FoodDeliveryFood Create (1.0ms)  INSERT INTO `food_delivery_foods` (`food_delivery_id`, `food_id`) VALUES (3, 1)
  FoodDeliveryFood Create (0.3ms)  INSERT INTO `food_delivery_foods` (`food_delivery_id`, `food_id`) VALUES (3, 2)
  TRANSACTION (2.7ms)  COMMIT
=> true

> food_delivery.delivery_minutes
=> 60

このように 60 が food_delivery.delivery_minutes の値となってしまいました……🤔

なにが起きていたのか?

ちょっとここからは自信ないので誤っているところがあれば指摘してほしいのですが、
Rails はデータベースとの接続確立後に、
各モデルの名称をもとにテーブルのスキーマ情報を取得しに行きます。

https://github.com/rails/rails/blob/aacbb5c0f5bdd11f0dee78da03bd6859f0cabeba/activerecord/lib/active_record/model_schema.rb#L532-L546

その中で、 データベース上でのデフォルト値も見に行くのです。

https://github.com/rails/rails/blob/v7.2.1/activerecord/lib/active_record/model_schema.rb#L472-L477

https://github.com/rails/rails/blob/v7.2.1/activerecord/lib/active_record/attributes.rb#L242-L254

これによって保持された @column_defaults は、 new メソッドのタイミングで呼び出されます。

https://github.com/rails/rails/blob/aacbb5c0f5bdd11f0dee78da03bd6859f0cabeba/activerecord/lib/active_record/inheritance.rb#L56-L78

よって、 food_delivery = FoodDelivery.new の段階で
food_delivery.delivery_minutes がすでに 60 に設定されていたために、
before_validation で self.delivery_minutes ||= ... をおこなっても値が更新されなかったわけですね!⭐

> food_delivery = FoodDelivery.new
=> #<FoodDelivery:0x0000ffffb126fa78 id: nil, delivery_minutes: 60>

解決方法

というわけで、デフォルト値をコールバックによって自動で設定したい場合は、
スキーマ定義の default 設定を消してあげる必要がありそうです。

# 配達1件あたりの情報
create_table :food_deliveries do |t|
-  t.integer :delivery_minutes, null: false, default: 60 # お届けにかかる時間(分)
+  t.integer :delivery_minutes, null: false # お届けにかかる時間(分)
end

このように変更すれば、 delivery_minutes がコールバックによって
想定通りに設定されるようになりました!!

> food_delivery = FoodDelivery.new
=> #<FoodDelivery:0x0000ffffb1228628 id: nil, delivery_minutes: nil>

> food_delivery.foods << Food.where(id: 1..2)
  Food Load (1.2ms)  SELECT `foods`.* FROM `foods` WHERE `foods`.`id` BETWEEN 1 AND 2
=> [
  #<Food:0x0000ffffb1062a78 id: 1, name: "カレー", cooking_minutes: 20>,
  #<Food:0x0000ffffb1076280 id: 2, name: "焼肉弁当", cooking_minutes: 40>
]

> food_delivery.save!
  TRANSACTION (0.6ms)  BEGIN
  FoodDelivery Create (1.1ms)  INSERT INTO `food_deliveries` (`delivery_minutes`) VALUES (70)
  FoodDeliveryFood Create (1.2ms)  INSERT INTO `food_delivery_foods` (`food_delivery_id`, `food_id`) VALUES (4, 1)
  FoodDeliveryFood Create (0.4ms)  INSERT INTO `food_delivery_foods` (`food_delivery_id`, `food_id`) VALUES (4, 2)
  TRANSACTION (3.9ms)  COMMIT
=> true

> food_delivery.delivery_minutes
=> 70

以上、スキーマ定義のデフォルト値が
Rails の挙動に与える影響の話でした!

ラブグラフのエンジニアブログ

Discussion