DelegatedType のバリデーションが上手く動かない件

2024/02/29に公開

ドーモ、株式会社ソーシャルPLUS CTO の サトウリョウスケ (@ryosuke_sato) です ✌︎('ω')✌︎

ちょっと昔の話になりますが、 Rails 6.1 で delegated type という機能が追加されました。一言で言えば STI と polymorphic を合わせたような機能でして、個人的に大好きな機能の一つです。

なにそれ美味しいの?という方は以下の記事を読んでみてください。自分も何度も読ませてもらった記事で、 delegated type で何ができるのかがよくわかると思います。

https://techracho.bpsinc.jp/hachi8833/2022_04_12/112882

さて、とても気に入っている 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 を指定してみる

前述のログを見ると、バリデーションの実行順序は MessageSendingConfigBackInStockAlert なので、 MessageSendingConfig のインスタンス (message_sending_config) は存在しているはずです。

これは delegated type 以外でも親子関係のモデルを扱う際によくハマるやつで、delegated_type :configurableinverse_of が指定されていないことが原因です。inverse_of を指定しないと MessageSendingConfig のインスタンスと BackInStockAlert#message_sending_config のインスタンスが同一(双方向関連付け)になりません。

ちなみに通常は foreign_keythrough などのオプションを使用しなければ自動で inverse_of が付与されるはずですが、 delegated_type の実態は belongs_topolymorphic: 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_configMessageSendingConfig のインスタンスを参照できるようにはなったんですが、今度は 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_idNULL となり、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 のバリデーションが実行されていないですね。この書き方だと通常の親子関係のモデルでも失敗する気がします。 MessageSendingConfigBackInStockAlert を別々に保存すれば動きそうですが、あまりスマートではないです。

とりあえず新規作成時に BackInStockAlert#message_sending_confignil になるのは防げるようになったので、しれっと書いていたボッチ演算子 (&.) は使わずに済みそうです。

- 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_typeinverse_of を指定する
  • accepts_nested_attributes_for を設定する

もし上手く動かないよ、というケースがあればコメントで教えていただけると嬉しいです🙇‍♂️
ちなみに、どういう場合に delegated type 使うべきか、そもそも設計が難しいという課題もあるんですが、こちらもいずれ記事にしてみたいと思ってます。

今回の記事で書いたコードはこちらにあります:
https://github.com/ryz310/validation-with-delegated-type

ではでは👋

参考

https://techracho.bpsinc.jp/hachi8833/2022_04_12/112882
https://techracho.bpsinc.jp/hachi8833/2022_04_20/117128

SocialPLUS Tech Blog

Discussion