Yutaka's blog

Rails model should validate itself based on the state of its own instance

Rails model validation is powerful, and it allows us to validate so many things. Although basic validations are available through validates, we can set custom validations with validate.

Sometimes, we have to implement validations that cannot be done through basic validates. For example, we have two models: User and FavoriteMusic, which are associated with each other in one-to-many. For some reason, our business logic requires that "a User instance cannot have more than 20 FavoriteMusic associations." What should I do?

Sometimes, we have to implement validations that cannot be done through basic validates. For example, we have two models: User and FavoriteMusic, which are associated with each other in one-to-many. For some reason, our business logic requires that "a User instance cannot have more than 20 FavoriteMusic associations." What should I do?

A straightforward way might be to use custom validation or to do a tricky way with length validation.

class User < ApplicationRecord
  has_many :favorite_musics

  validates :favorite_musics, length: { maximum: 20 }
end

class FavoriteMusic < ApplicationRecord
  belongs_to :user
end

When creating User models, it seems to work. However, there's a catch when operating with FavoriteMusic.

irb(main):001:0> user = FactoryBot.create(:user)
irb(main):002:0> FactoryBot.create_list(:favorite_music, 21, user: user)
irb(main):003:0> user.reload.favorite_musics.count
=> 21

OK, so maybe this validation should be done within FavoriteMusic. How about this?

class User < ApplicationRecord
  has_many :favorite_musics
end

class FavoriteMusic < ApplicationRecord
  belongs_to :user

  validate :restrict_number_of_records

  private

  def restrict_number_of_records
    unless self.class.where(user: user).count < 20
      errors.add(:base, "The number of records for a user must be less than or equal to 20")
    end
  end
end
irb(main):001:0> user = FactoryBot.create(:user)
irb(main):002:0> FactoryBot.create_list(:favorite_music, 21, user: user)
...
  TRANSACTION (0.1ms)  ROLLBACK TO SAVEPOINT active_record_1
/Users/yykamei/.rbenv/versions/3.1.0/lib/ruby/gems/3.1.0/gems/activerecord-7.0.2.2/lib/active_record/validations.rb:80:in `raise_validation_error': Validation failed: The number of records for a user must be less than or equal to 20 (ActiveRecord::RecordInvalid)
irb(main):003:0>
irb(main):004:0> user2 = FactoryBot.create(:user)
irb(main):005:0> FactoryBot.create_list(:favorite_music, 20, user: user2)
irb(main):006:0> user2.reload.favorite_musics << FactoryBot.build(:favorite_music)
  User Load (0.1ms)  SELECT "users".* FROM "users" WHERE "users"."id" = ? LIMIT ?  [["id", 1], ["LIMIT", 1]]
  TRANSACTION (0.1ms)  SAVEPOINT active_record_1
  FavoriteMusic Count (0.2ms)  SELECT COUNT(*) FROM "favorite_musics" WHERE "favorite_musics"."user_id" = ?  [["user_id", 1]]
  TRANSACTION (0.1ms)  ROLLBACK TO SAVEPOINT active_record_1
=> nil

It looks like working. Our models rejected creating the 21st record of FavoriteMusic.

Validation by counting records really works?

I'm not sure the future of any applications, but there would be a case that a race condition occurs. When one transaction checks the number of FavoriteMusic as another does so at the same time and both of them confirms the record count is 19, the 21st record could be created.

"No problem, our application will be used only by our company staff."

Right, I agree. such a case rarely happens, but I feel there is a smell that the validation is relying on the state of records. First, an already inserted FavoriteMusic, which was valid, might become invalid when updating it because of previous accidental creation. Of course, this could be avoided with contexts for validate like this.

validate :restrict_number_of_records, on: :create

Still, I think it has a smell. Validating the number of records seems to be beyond the responsibility of a single model. Database records intrinsically grow indefinitely, so it doesn't make sense that a single model validates a part of an entire table state. The same is true of uniqueness validation.

Besides, checking other records requires calling SQL. If your application grows rapidly and has a performance issue, it might be difficult to detect the bottleneck of SQL in validate.

So how to achieve the goal?

I want to ask "Why should we restrict the number of records?" Does it matter for UI (e.g., data should be fit into the narrow section of a UI)? If so, won't limiting records work when showing the UI? If the number of records stored in the database must be definitely within the specified number, then we must manage the number of records in a dedicated table, and a lock might be required (I know that's not ideal in most cases).

We as developers should research the requirements of business logic carefully, and update it to fit into our running application.