Skip to main content

Custom Validations Issue

Rails • Asked by Thomas Bush
9c23af4ac1ba2152ff33776713e0c297

I have a custom validator that I would like to protect some data on my Users to Sites has_many through relaitonship. There is one extra piece of data on the sites_users join table is_default which is type boolean with the intention being to allow each user to have exactly one default site from the list of related sites for that user.

Instead of testing the current form instance, I am getting results in my validator to these two methods based on the prevesiously saved db instance for this same record. So if the user I am editing has one related site, but the current form isntance would remove all related sites the validation will still pass, even thought I am attempting to ensure at least one site present. Also, if the user I am editing has only one default_site, but the current form instance would set three more default sites, this passes as well, even though I am trying to validate that exactly one site is listed as default.

I have included my user validator and model code below. I can't quite figure out how to validate against the form instance isntead of the previously saved instance. I would really appreciate any help anyone could provide. Thanks you!

Here is my custom validator:

** user_validator.rb**

class UserValidator < ActiveModel::Validator
  def validate(record)
    if record.sites.count < 1
      record.errors.add(:site, "must have at least one site available")
    end

    if default_site.count != 1
      record.errors.add(:site, "must have exactly one site selected as default")
    end
  end
end

and this is my user model
** user.rb**

class User < ApplicationRecord
    has_many :sites_users
    has_many :sites, through: :sites_users
    accepts_nested_attributes_for :sites_users, allow_destroy: true
    ...
    def default_site
      sites.joins(:users).where(sites_users: { is_default: true } )
    end
    ...
end

Ce795239ba5dd2384fc2f88ffaff5451

Hey Thomas,

The main issue here is your default_site method I believe. Here's the problem:

Your default_site method issues a database query.

When your form submits, you actually are modifying the record (and nested records) in memory. When your validation runs, it needs to check against the in-memory nested records that you're submitting, not the ones in the database.

class UserValidator < ActiveModel::Validator
  def validate(record)
    if record.sites.count < 1
      record.errors.add(:site, "must have at least one site available")
    end

    if record.sites_users.select{ |su| su.is_default }.count != 1
      record.errors.add(:site, "must have exactly one site selected as default")
    end
  end
end

Changing it to this will do a check against the sites_users in-memory. You can leave your database query for default site as it does some extra loading for performance elsewhere I'm assuming.


9c23af4ac1ba2152ff33776713e0c297

Chris,

That solves the single default for each user, totally clears up my confusion, too thanks.

Oddly though, the record.sites.count < 1 allows me to delete all sites for a users and save without validation error. This feels like it should contain the in-memory instance, what am I missing?


Ce795239ba5dd2384fc2f88ffaff5451

Probably the same issue.

Remember that count, size, and length in ActiveRecord are all different. Count always queries the database if I remember right. Size uses the counter cache if it can, but will count the in-memory items if they're loaded. Length always counts them in Ruby in-memory.

You probably want to use either size or length there to make sure you're counting the Ruby array, not the db query. (It would also probably be good to comment these nuances alongside the code so when this changes in the future you remember why you did these seemingly weird things.)

Reference:
https://mensfeld.pl/2014/09/activerecord-count-vs-length-vs-size-and-what-will-happen-if-you-use-it-the-way-you-shouldnt/


9c23af4ac1ba2152ff33776713e0c297

Chris,

Thanks for your help!!! I have included my final solution below on the off chance it could help someone else. Ended up discovering one more edge case, the user has two sites marked as default and deletes one of them -- the select call would still trigger a validation error.

After a bit of searching I found .marked_for_destruction? method which is really useful for nested attribute scenarios to validate against only the in-memory data a user intends to keep, instead of all in-memory data which would obviously include those records marked for deletion with "_destroy"=>"true" params

class UserValidator < ActiveModel::Validator
  def validate(record)
    if record.sites_users.select { |su| su.marked_for_destruction? == false }.count < 1
      record.errors.add(:site, "must have at least one site available")
    end

    if record.sites_users.select{ |su| su.marked_for_destruction? == false && su.is_default }.count != 1
      record.errors.add(:site, "must have exactly one site selected as default")
    end
  end
end

Thank you!


Ce795239ba5dd2384fc2f88ffaff5451

Ah yes, that would break it too!

A small refactoring would be:

class UserValidator < ActiveModel::Validator
  def validate(record)
      site_users = record.sites_users.select { |su| su.marked_for_destruction? == false }

    if site_users.count < 1
      record.errors.add(:site, "must have at least one site available")
    end

    if sites_users.select(&:is_default).count != 1
      record.errors.add(:site, "must have exactly one site selected as default")
    end
  end
end

9c23af4ac1ba2152ff33776713e0c297

Even better, thanks again!


Login or Create An Account to join the conversation.

Subscribe to the newsletter

Join 18,000+ developers who get early access to new screencasts, articles, guides, updates, and more.

By clicking this button, you agree to the GoRails Terms of Service and Privacy Policy.

More of a social being? We're also on Twitter and YouTube.