DelegatedType のバリデーションが上手く動かない件
ドーモ、株式会社ソーシャルPLUS CTO の サトウリョウスケ (@ryosuke_sato) です ✌︎('ω')✌︎
ちょっと昔の話になりますが、 Rails 6.1 で delegated type という機能が追加されました。一言で言えば STI と polymorphic を合わせたような機能でして、個人的に大好きな機能の一つです。
なにそれ美味しいの?という方は以下の記事を読んでみてください。自分も何度も読ませてもらった記事で、 delegated type で何ができるのかがよくわかると思います。
さて、とても気に入っている delegated type なんですが、使っているうちにうちに幾つかハマりポイントがあることに気づきました。今回は delegated type でバリデーションが上手く動かないという落とし穴に落ちた時の話をしようと思います🕳️
どこでハマったか
まずは以下のソースを見て下さい:
# Table name: message_sending_configs
# id :integer not null, primary key
# configurable_type :string not null
# configurable_id :integer not null
# enabled :boolean default(TRUE), not null
class MessageSendingConfig < ApplicationRecord
delegated_type :configurable, dependent: :destroy, types: %w[
BackInStockAlert
]
end
# Table name: back_in_stock_alerts
# id :integer not null, primary key
# api_key :string default(""), not null
# message_body :text
class BackInStockAlert < ApplicationRecord
has_one :message_sending_config,
as: :configurable,
dependent: :destroy,
touch: true
# message_sending_config が有効な場合のみ api_key を必須にしたい
validate :api_key, presence: true, if: -> { message_sending_config&.enabled? }
end
弊社のプロダクトでは LINE を使ったメッセージ配信機能を多く実装していて、上記のモデルは LINE を使った自動配信機能の設定を管理するモデル(を簡素化したもの)です。提供している自動配信には様々な種類があるんですが、配信設定の大半は共通だったりするので delegated type が適していると考えました。
MessageSendingConfig
は配信設定の共通部分を管理するモデル、 BackInStockAlert
は品切れの商品が再入荷した際に自動通知するための設定を管理するモデルです。
注目して欲しいのは以下の部分:
# message_sending_config が有効な場合のみ api_key を必須にしたい
validate :api_key, presence: true, if: -> { message_sending_config&.enabled? }
配信設定は顧客企業の管理画面から設定してもらうのですが、設定が有効な場合 (enabled = true
) のみ api_key
を必須パラメータにしたいという要望がありました。
この実装だとどうなるのか
一見良さそうですが、実際に動かしてみるとこのコードは上手く動きません:
# 新規作成の場合
MessageSendingConfig.create!(
enabled: true,
configurable: BackInStockAlert.new(api_key: '')
) # => #<MessageSendingConfig:0x000000010f1f4ba8>
# バリデーションをすり抜けて保存に成功してしまう!😳
# 更新の場合
config = MessageSendingConfig.create!(
enabled: false,
configurable: BackInStockAlert.new(api_key: '')
)
config.configurable.api_key = ''
config.update!(enabled: true) # => true
# バリデーションをすり抜けて保存に成功してしまう!😳
何故バリデーションが上手く動かないのか
先ほどのコードにログ出力を追加して確認してみましょう:
class MessageSendingConfig < ApplicationRecord
delegated_type :configurable, dependent: :destroy, types: %w[
BackInStockAlert
]
before_validation lambda {
puts '=' * 100
puts 'before_validation at MessageSendingConfig'
puts '=' * 100
}
end
class BackInStockAlert < ApplicationRecord
has_one :message_sending_config, as: :configurable, dependent: :destroy, touch: true
# message_sending_config が有効な場合のみ api_key を必須にしたい
validates :api_key, presence: true, if: lambda {
puts '=' * 100
puts 'validates :api_key, presence: true'
puts "message_sending_config : #{message_sending_config.inspect}"
puts '=' * 100
message_sending_config&.enabled?
}
before_validation lambda {
puts '=' * 100
puts 'before_validation at BackInStockAlert'
puts '=' * 100
}
end
もう一度実行してみます:
# 新規作成の場合
MessageSendingConfig.create!(
enabled: true,
configurable: BackInStockAlert.new(api_key: '')
)
====================================================================================================
before_validation at MessageSendingConfig
====================================================================================================
before_validation at BackInStockAlert
====================================================================================================
validates :api_key, presence: true
message_sending_config : nil
====================================================================================================
バリデーション実行時に message_sending_config : nil
になっていました。
inverse_of
を指定してみる
前述のログを見ると、バリデーションの実行順序は MessageSendingConfig
→ BackInStockAlert
なので、 MessageSendingConfig
のインスタンス (message_sending_config
) は存在しているはずです。
これは delegated type 以外でも親子関係のモデルを扱う際によくハマるやつで、delegated_type :configurable
に inverse_of
が指定されていないことが原因です。inverse_of
を指定しないと MessageSendingConfig
のインスタンスと BackInStockAlert#message_sending_config
のインスタンスが同一(双方向関連付け)になりません。
ちなみに通常は foreign_key
や through
などのオプションを使用しなければ自動で inverse_of
が付与されるはずですが、 delegated_type
の実態は belongs_to
に polymorphic: true
を指定したもの なので、明示的に inverse_of
を指定する必要があるようです。
修正してもう一度試してみます:
class MessageSendingConfig < ApplicationRecord
delegated_type :configurable, dependent: :destroy, types: %w[
BackInStockAlert
], inverse_of: :message_sending_config # ← 追加
before_validation lambda {
puts '=' * 100
puts 'before_validation at MessageSendingConfig'
puts '=' * 100
}
end
# 新規作成の場合
MessageSendingConfig.create!(
enabled: true,
configurable: BackInStockAlert.new(api_key: '')
) # => ActiveRecord::NotNullViolation 例外が発生する
====================================================================================================
before_validation at MessageSendingConfig
====================================================================================================
before_validation at BackInStockAlert
====================================================================================================
validates :api_key, presence: true
message_sending_config : #<MessageSendingConfig id: nil, configurable_type: "BackInStockAlert", configurable_id: nil, enabled: true, created_at: nil, updated_at: nil>
====================================================================================================
めでたく BackInStockAlert#message_sending_config
が MessageSendingConfig
のインスタンスを参照できるようにはなったんですが、今度は ActiveRecord::NotNullViolation
例外が発生してしまいました。
ログを確認してみましょう:
TRANSACTION (0.0ms) begin transaction
MessageSendingConfig Create (1.0ms) INSERT INTO "message_sending_configs" ("configurable_type", "configurable_id", "enabled", "created_at", "updated_at") VALUES (?, ?, ?, ?, ?) RETURNING "id" [["configurable_type", "BackInStockAlert"], ["configurable_id", nil], ["enabled", 1], ["created_at", "2024-02-28 08:45:43.058908"], ["updated_at", "2024-02-28 08:45:43.058908"]]
TRANSACTION (0.0ms) rollback transaction
SQLite3::ConstraintException: NOT NULL constraint failed: message_sending_configs.configurable_id (ActiveRecord::NotNullViolation)
NOT NULL constraint failed: message_sending_configs.configurable_id (SQLite3::ConstraintException)
どうやら BackInStockAlert
の保存に失敗して、そのまま MessageSendingConfig
の保存を実行してしまったようです。BackInStockAlert
のレコードが DB に保存できていないので configurable_id
が NULL
となり、DB の NOT NULL 制約に引っかかってしまいました。
ちなみに更新の方はどうでしょうか?
# 更新の場合
config = MessageSendingConfig.create!(
enabled: false,
configurable: BackInStockAlert.new(api_key: '')
)
config.configurable.api_key = ''
config.update!(enabled: true) # => true
====================================================================================================
before_validation at MessageSendingConfig
====================================================================================================
またしてもバリデーションをすり抜けて保存に成功してしまいました 😇
そもそも BackInStockAlert
のバリデーションが実行されていないですね。この書き方だと通常の親子関係のモデルでも失敗する気がします。 MessageSendingConfig
と BackInStockAlert
を別々に保存すれば動きそうですが、あまりスマートではないです。
とりあえず新規作成時に BackInStockAlert#message_sending_config
が nil
になるのは防げるようになったので、しれっと書いていたボッチ演算子 (&.
) は使わずに済みそうです。
- validate :api_key, presence: true, if: -> { message_sending_config&.enabled? }
+ validate :api_key, presence: true, if: -> { message_sending_config.enabled? }
accepts_nested_attributes_for
を指定してみる
Rails 7.0 から delegated type でも accepts_nested_attributes_for
が使えるようになりました。accepts_nested_attributes_for
といえばバッドノウハウじゃないの、と言われていた時期もあるんですが、実際のところどうなんですかね?自分ではあまりハマった記憶がないので、もし辛かった想い出がある方はコメント頂けると🙏
さて、 accepts_nested_attributes_for
の素敵な機能に、子モデルのバリデーションエラーを親モデルに伝搬させてくれる、というものがあります。これを試してみましょう。
class MessageSendingConfig < ApplicationRecord
delegated_type :configurable, dependent: :destroy, types: %w[
BackInStockAlert
], inverse_of: :message_sending_config
accepts_nested_attributes_for :configurable # ← 追加
before_validation lambda {
puts '=' * 100
puts 'before_validation at MessageSendingConfig'
puts '=' * 100
}
end
実行してみましょう。 accepts_nested_attributes_for
を使うので、パラメータの指定方法が少し変わります (configurable_attributes
の部分)。
# 新規作成の場合
MessageSendingConfig.create!(
enabled: true,
configurable_type: 'BackInStockAlert',
configurable_attributes: {
api_key: ''
}
) # ActiveRecord::RecordInvalid 例外が発生!
====================================================================================================
before_validation at MessageSendingConfig
====================================================================================================
before_validation at BackInStockAlert
====================================================================================================
validates :api_key, presence: true
message_sending_config : #<MessageSendingConfig id: nil, configurable_type: "BackInStockAlert", configurable_id: nil, enabled: true, created_at: nil, updated_at: nil>
====================================================================================================
Validation failed: Configurable api key can't be blank (ActiveRecord::RecordInvalid)
お。やりました!期待通りバリデーションエラーになっています!
では更新の方はどうでしょうか?
# 更新の場合
config = MessageSendingConfig.create!(
enabled: false,
configurable_type: 'BackInStockAlert',
configurable_attributes: {
api_key: 'xxx'
}
)
config.update!(
enabled: true,
configurable_attributes: { api_key: '' }
) # ActiveRecord::RecordInvalid 例外が発生!
====================================================================================================
before_validation at MessageSendingConfig
====================================================================================================
before_validation at BackInStockAlert
====================================================================================================
validates :api_key, presence: true
message_sending_config : #<MessageSendingConfig id: 25, configurable_type: "BackInStockAlert", configurable_id: nil, enabled: true, created_at: "2024-02-28 13:24:13.186841000 +0000", updated_at: "2024-02-28 13:24:13.187265000 +0000">
====================================================================================================
Validation failed: Configurable api key can't be blank (ActiveRecord::RecordInvalid)
こちらも期待通りの動きになりました!🙌 🙌 🙌 🙌 🙌 🙌
delegated type の accepts_nested_attributes_for
って地味なやつかと思ってたんですが、実は神アプデだったんですね✨ 一生ついていきます!
まとめ
というわけで、 delegated type では以下の設定にするとバリデーションでハマらずに捗ります:
# Table name: message_sending_configs
# id :integer not null, primary key
# configurable_type :string not null
# configurable_id :integer not null
# enabled :boolean default(TRUE), not null
class MessageSendingConfig < ApplicationRecord
delegated_type :configurable, dependent: :destroy, types: %w[
BackInStockAlert
], inverse_of: :message_sending_config # ← これ。めっちゃ大事。
# configurable のバリデーションエラーを伝搬させるために必要
accepts_nested_attributes_for :configurable # ← これもめっちゃ大事。
end
# Table name: back_in_stock_alerts
# id :integer not null, primary key
# api_key :string default(""), not null
# message_body :text
class BackInStockAlert < ApplicationRecord
has_one :message_sending_config,
as: :configurable,
dependent: :destroy,
touch: true
# message_sending_config が有効な場合のみ api_key を必須にしたい
validate :api_key, presence: true, if: -> { message_sending_config.enabled? }
end
ポイント
-
delegated_type
にinverse_of
を指定する -
accepts_nested_attributes_for
を設定する
もし上手く動かないよ、というケースがあればコメントで教えていただけると嬉しいです🙇♂️
ちなみに、どういう場合に delegated type 使うべきか、そもそも設計が難しいという課題もあるんですが、こちらもいずれ記事にしてみたいと思ってます。
今回の記事で書いたコードはこちらにあります:
ではでは👋
参考
Discussion