Rails のコールバックでデフォルト値を入れるのに失敗した話
ラブグラフでCTOやエンジニアをしております横江( @yokoe24 )です!
ハマりがちな Rails 独特の挙動について紹介します。
前提情報(テーブル&モデル)
店舗からのフードデリバリーを管理する Web アプリ で、
以下のようなテーブルやモデルがあったとします。
テーブル
-
food_deliveries
: 配達1件あたりの情報 -
foods
: 配達する食品の情報 -
food_delivery_foods
: 配達1件でお届けする食品一覧を格納する中間テーブル
(以下は ridgepole の Schemafile.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 はデータベースとの接続確立後に、
各モデルの名称をもとにテーブルのスキーマ情報を取得しに行きます。
その中で、 データベース上でのデフォルト値も見に行くのです。
これによって保持された @column_defaults
は、 new メソッドのタイミングで呼び出されます。
よって、 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