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.