レコード数のバリデーション、特に「特定の状態を持つ」レコード数によるバリデーションについての記事が少なく、実装に時間をかけてしまったため備忘のために記事を残そうと思います。

単純なケース

例として、ユーザー (User) と記事 (Article) が

class User < ActiveRecord
  has_many :articles
end
class Article < ActiveRecord
  belongs_to :user
end

という関係のとき、「ユーザーは記事を3個までしか投稿できない」というバリデーションをつけたいとします。このとき、以下のようなバリデーションが考えられます。

class Article < ActiveRecord
  belongs_to :user

  validate :check_count, on: :create

  def check_count
    limit = 3
    if user && user.articles.size >= limit
      errors.add(:user, "記事は#{limit}個まで投稿可能です")
    end
  end
end

これで記事を4個以上登録しようとするとバリデーションエラーが発生するようになりました。

irb(main):001> user = User.create!(name: "Alice")
irb(main):002> user.articles << Article.create!(title: "1", user:)
irb(main):003> user.articles << Article.create!(title: "2", user:)
irb(main):004> user.articles << Article.create!(title: "3", user:)
irb(main):005> user.articles << Article.create!(title: "4", user:)
(irb):5:in `<main>': Validation failed: User 記事は3個まで投稿可能です (ActiveRecord::RecordInvalid)

なお on: :create をつけて新規作成時に限定しているのは、既存の記事を更新する際にまでバリデーションが発動してしまって記事が更新できなくなるのを防ぐためです。

ちょっと複雑なケース

記事に is_draft カラムを生やして、下書き機能を実装したとします。is_draft: true の記事が下書きの記事、is_draft: false の記事が本番投稿の記事です。

それにともない、「ユーザーは下書きの記事は制限なく登録できるが、投稿は3個までしかできない」というバリデーションをかけたいと思いました。

シンプルに考えると、以下のようなバリデーションになると思います。

class Article < ActiveRecord
  belongs_to :user

  validate :check_count, on: :create

  def check_count
    limit = 3
    # 下書きではない記事の個数のみカウント
    if user && user.articles.where(is_draft: false).size >= limit
      errors.add(:user, "記事は#{limit}個まで投稿可能です")
    end
  end
end

しかしこれだと問題が発生します。まず本番投稿済みの記事がすでに3個あると下書きであっても記事を登録することができなくなります。

irb(main):001> user = User.create!(name: "Bob")
irb(main):002> user.articles << Article.create!(title: "1", is_draft: false, user:)
irb(main):003> user.articles << Article.create!(title: "2", is_draft: false, user:)
irb(main):004> user.articles << Article.create!(title: "3", is_draft: false, user:)

# 下書きの記事であっても投稿できない
irb(main):005> user.articles << Article.create!(title: "4", is_draft: true, user:)
(irb):5:in `<main>': Validation failed: User 記事は3個まで投稿可能です (ActiveRecord::RecordInvalid)

また、下書き記事がすでに4個以上ある状態で、それらの記事を本番投稿に切り替えると、4個以上の記事が投稿できたことになってしまいます。

irb(main):001> user = User.create!(name: "Carol")
irb(main):002> user.articles << Article.create!(title: "1", is_draft: true, user:)
irb(main):003> user.articles << Article.create!(title: "2", is_draft: true, user:)
irb(main):004> user.articles << Article.create!(title: "3", is_draft: true, user:)
irb(main):005> user.articles << Article.create!(title: "4", is_draft: true, user:)

# 本番投稿の記事が4個以上になってしまう
irb(main):006> user.articles.update!(is_draft: false)
=>
[#<Article:0x000000010ce32c10 id: 1, title: "1", is_draft: false, content: nil, created_at: Sat, 11 May 2024 04:38:12.738706000 UTC +00:00, updated_at: Sat, 11 May 2024 04:39:32.751221000 UTC +00:00, user_id: 1>,
 #<Article:0x000000010dc52098 id: 2, title: "2", is_draft: false, content: nil, created_at: Sat, 11 May 2024 04:38:16.645629000 UTC +00:00, updated_at: Sat, 11 May 2024 04:39:32.754477000 UTC +00:00, user_id: 1>,
 #<Article:0x000000010dd7ac90 id: 3, title: "3", is_draft: false, content: nil, created_at: Sat, 11 May 2024 04:38:19.818651000 UTC +00:00, updated_at: Sat, 11 May 2024 04:39:32.755822000 UTC +00:00, user_id: 1>,
 #<Article:0x000000010de30f18 id: 4, title: "4", is_draft: false, content: nil, created_at: Sat, 11 May 2024 04:38:23.176137000 UTC +00:00, updated_at: Sat, 11 May 2024 04:39:32.756758000 UTC +00:00, user_id: 1>]

解決方法

これを解決するために、バリデーションの発火条件を「新規作成時」から、「is_draftfalse のとき = 本番投稿のとき」に変更します。

class Article < ApplicationRecord
  belongs_to :user

  validate :check_count, if: ->(record) { record.is_draft == false }

  def check_count
    limit = 3
    if user && user.articles.where(is_draft: false).size >= limit
      errors.add(:user, "記事は#{limit}個まで投稿可能です")
    end
  end
end

こうすることで、上記のケースも問題なくバリデーションが機能するようになりました。

irb(main):001> user = User.create!(name: "Bob")
irb(main):002> user.articles << Article.create!(title: "1", is_draft: false, user:)
irb(main):003> user.articles << Article.create!(title: "2", is_draft: false, user:)
irb(main):004> user.articles << Article.create!(title: "3", is_draft: false, user:)

# 本番記事はバリデーションエラー
irb(main):005> user.articles << Article.create!(title: "4", is_draft: false, user:)
(irb):5:in `<main>': Validation failed: User 記事は3個まで投稿可能です (ActiveRecord::RecordInvalid)
# 下書き記事はバリデーションエラーにならない
irb(main):006> user.articles << Article.create!(title: "4", is_draft: true, user:)
=>
[#<Article:0x0000000105a56058 id: 1, title: "1", is_draft: false, content: nil, created_at: Sat, 11 May 2024 05:39:14.176248000 UTC +00:00, updated_at: Sat, 11 May 2024 05:39:14.176248000 UTC +00:00, user_id: 1>,
 #<Article:0x0000000105a55f18 id: 2, title: "2", is_draft: false, content: nil, created_at: Sat, 11 May 2024 05:39:17.438778000 UTC +00:00, updated_at: Sat, 11 May 2024 05:39:17.438778000 UTC +00:00, user_id: 1>,
 #<Article:0x0000000105a55dd8 id: 3, title: "3", is_draft: false, content: nil, created_at: Sat, 11 May 2024 05:39:20.684739000 UTC +00:00, updated_at: Sat, 11 May 2024 05:39:20.684739000 UTC +00:00, user_id: 1>,
 #<Article:0x0000000105a55c98 id: 4, title: "4", is_draft: true, content: nil, created_at: Sat, 11 May 2024 05:39:28.858217000 UTC +00:00, updated_at: Sat, 11 May 2024 05:39:28.858217000 UTC +00:00, user_id: 1>]

# 後から本番記事に変更しようとするとバリデーションエラー
irb(main):007> Article.find(4).update!(is_draft: false)
(irb):7:in `<main>': Validation failed: User 記事は3個まで投稿可能です (ActiveRecord::RecordInvalid)

あとがき

本当は new_record?will_save_change_to_attribute? を使ってバリデーションの発火条件を制御した…という話を書きたかったんですが、記事を書いている途中の検証でうまくいかないことがわかってしまい、カラムの値を参照するという結論になってしまいました。

参考