15

I have this model:

class Campaign

  include Mongoid::Document
  include Mongoid::Timestamps

  field :name, :type => String
  field :subdomain, :type => String
  field :intro, :type => String
  field :body, :type => String
  field :emails, :type => Array
end

Now I want to validate that each email in the emails array is formatted correctly. I read the Mongoid and ActiveModel::Validations documentation but I didn't find how to do this.

Can you show me a pointer?

the Tin Man
  • 158,662
  • 42
  • 215
  • 303
Antonio Pardo
  • 189
  • 2
  • 2
  • 7
  • 2
    Be aware that "validating" an email address has multiple meanings: You can make a cursory pass at finding out whether the address is formatted correctly using a simple regex, like the answers show, however no regex can cover all the corner cases the email spec allows. You can also validate whether an address is valid by sending an email to that address and, if it's delivered, then the address is considered valid. That is the preferred way of doing it because, though an email might be syntactically correct, it might not be "alive". – the Tin Man Apr 15 '11 at 15:07
  • 3
    Agreed. Except for the part about "no regex can cover all the corner cases the email spec allows"... that's just not true. It's a mean regex, but it's do-able. – Ryan Long Aug 31 '12 at 02:03
  • I suggest you to [check official LINK](http://guides.rubyonrails.org/active_record_validations.html#length) – Marco Sanfilippo Apr 23 '17 at 09:22

5 Answers5

27

You can define custom ArrayValidator. Place following in app/validators/array_validator.rb:

class ArrayValidator < ActiveModel::EachValidator
  def validate_each(record, attribute, values)
    Array(values).each do |value|
      options.each do |key, args|
        validator_options = { attributes: attribute }
        validator_options.merge!(args) if args.is_a?(Hash)

        next if value.nil? && validator_options[:allow_nil]
        next if value.blank? && validator_options[:allow_blank]

        validator_class_name = "#{key.to_s.camelize}Validator"
        validator_class = begin
          validator_class_name.constantize
        rescue NameError
          "ActiveModel::Validations::#{validator_class_name}".constantize
        end

        validator = validator_class.new(validator_options)
        validator.validate_each(record, attribute, value)
      end
    end
  end
end

You can use it like this in your models:

class User
  include Mongoid::Document
  field :tags, Array

  validates :tags, array: { presence: true, inclusion: { in: %w{ ruby rails } }
end

It will validate each element from the array against every validator specified within array hash.

Milovan Zogovic
  • 1,541
  • 2
  • 16
  • 24
  • This works great, thanks! One change I would make is to add a conditional to make sure that the array is populated. That way, if you don't require the presence of that field, it won't try to validate it if it's blank. Otherwise, you get an error. So, my version would wrap the entire method within `if values.present?` – monfresh May 31 '13 at 17:06
  • why flatten? accepting nested arrays sounds like a bug to me. – Eamon Nerbonne Jul 31 '13 at 13:18
  • Flatten is used to handle both single and array like values (e.g. passing single tag, or passing array of tags). Can't tell exactly why this was important since i wrote this long time ago, but there was a reason, I am sure :) You are right about nested arrays. – Milovan Zogovic Aug 02 '13 at 07:06
  • @MilovanZogovic neat idea to do this validator. In the answer I added you'll see a refactored version that fixes several bugs, improves performance and adds nice error messages. – Sim Sep 11 '13 at 04:58
  • Not working for me using :if or :on options. `validates :color, presence: true, array: { inclusion: { in: ['red', 'green'] } }, on: :update, if: :some_method` – phlegx Feb 17 '15 at 16:31
  • 1
    Instead of using `[values].flatten` it should be `Array(values)`. `Array(arr)` will not create a nested array if given an array. This way the values won't be flattened and the validator will fail for nested arrays. – lukad May 13 '19 at 15:50
  • 1
    Thanks @lukad - i agree with you. I've updated the code above. – Milovan Zogovic May 15 '19 at 13:29
  • It would be useful to have a **gem** for this – collimarco Oct 10 '19 at 11:39
15

Milovan's answer got an upvote from me but the implementation has a few problems:

  1. Flattening nested arrays changes behavior and hides invalid values.

  2. nil field values are treated as [nil], which doesn't seem right.

  3. The provided example, with presence: true will generate a NotImplementedError error because PresenceValidator does not implement validate_each.

  4. Instantiating a new validator instance for every value in the array on every validation is rather inefficient.

  5. The generated error messages do not show why element of the array is invalid, which creates a poor user experience.

Here is an updated enumerable and array validator that addresses all these issues. The code is included below for convenience.

# Validates the values of an Enumerable with other validators.
# Generates error messages that include the index and value of
# invalid elements.
#
# Example:
#
#   validates :values, enum: { presence: true, inclusion: { in: %w{ big small } } }
#
class EnumValidator < ActiveModel::EachValidator

  def initialize(options)
    super
    @validators = options.map do |(key, args)|
      create_validator(key, args)
    end
  end

  def validate_each(record, attribute, values)
    helper = Helper.new(@validators, record, attribute)
    Array.wrap(values).each do |value|
      helper.validate(value)
    end
  end

  private

  class Helper

    def initialize(validators, record, attribute)
      @validators = validators
      @record = record
      @attribute = attribute
      @count = -1
    end

    def validate(value)
      @count += 1
      @validators.each do |validator|
        next if value.nil? && validator.options[:allow_nil]
        next if value.blank? && validator.options[:allow_blank]
        validate_with(validator, value)
      end
    end

    def validate_with(validator, value)
      before_errors = error_count
      run_validator(validator, value)
      if error_count > before_errors
        prefix = "element #{@count} (#{value}) "
        (before_errors...error_count).each do |pos|
          error_messages[pos] = prefix + error_messages[pos]
        end
      end
    end

    def run_validator(validator, value)
      validator.validate_each(@record, @attribute, value)
    rescue NotImplementedError
      validator.validate(@record)
    end

    def error_messages
      @record.errors.messages[@attribute]
    end

    def error_count
      error_messages ? error_messages.length : 0
    end
  end

  def create_validator(key, args)
    opts = {attributes: attributes}
    opts.merge!(args) if args.kind_of?(Hash)
    validator_class(key).new(opts).tap do |validator|
      validator.check_validity!
    end
  end

  def validator_class(key)
    validator_class_name = "#{key.to_s.camelize}Validator"
    validator_class_name.constantize
  rescue NameError
    "ActiveModel::Validations::#{validator_class_name}".constantize
  end
end
Sim
  • 13,147
  • 9
  • 66
  • 95
  • thumbs up for this one! – Milovan Zogovic Sep 11 '13 at 05:56
  • Sim, this is great, thanks! Question, is there any way to have the error show up in a flash message? When I try to submit an unacceptable item in the form, rather than giving me a nice looking error message, it takes me to the typical active record errors page. I'd like to just put the error in a flash message. – Philip7899 Nov 11 '13 at 17:55
  • I have created a question of this comment here: http://stackoverflow.com/questions/19913373/change-validation-error-from-active-record-error-view-page-to-clean-flash-messag – Philip7899 Nov 11 '13 at 18:36
  • I actually got an answer to that. One more question though, how do I get the error message to include all of the invalid entries? Right now, prefix can only be one item from the array. Do you want me to make this a seperate question? – Philip7899 Nov 11 '13 at 19:41
  • 2
    In Rails 4.1, the `options` now include a new `:class` key (https://github.com/rails/rails/blob/7d84c3a2f7ede0e8d04540e9c0640de7378e9b3a/activemodel/lib/active_model/validator.rb#L85-94), so this will break with this error: `uninitialized constant ActiveModel::Validations::ClassValidator`. To fix, remove that key from the options in the `initialize` method. See the code in my comment on Sim's gist: https://gist.github.com/ssimeonov/6519423#comment-1246177 – monfresh Jun 14 '14 at 22:14
  • @monfresh +1 one for keeping up with Rails. – Sim Jun 15 '14 at 22:44
  • I suppouse there's a problem: some of rails' validators like `NumericalityValidator` use `#{attribute_name}_before_type_cast` and it returns the entire array instead of a single element. I worked around this issue by temporary overring the method. Check it out: https://gist.github.com/amenzhinsky/c961f889a78f4557ae0b – amenzhinsky Jan 05 '15 at 14:54
  • @Sim Can you release this as a gem? – collimarco Oct 10 '19 at 11:46
  • @collimarco I don't have the bandwidth at this time. – Sim Oct 12 '19 at 03:55
6

You'll probably want to define your own custom validator for the emails field.

So you'll add after your class definition,

validate :validate_emails

def validate_emails
  invalid_emails = self.emails.map{ |email| email.match(/^([^@\s]+)@((?:[-a-z0-9]+\.)+[a-z]{2,})$/i) }.select{ |e| e != nil }
  errors.add(:emails, 'invalid email address') unless invalid_emails.empty?
end

The regex itself may not be perfect, but this is the basic idea. You can check out the rails guide as follows:

http://guides.rubyonrails.org/v2.3.8/activerecord_validations_callbacks.html#creating-custom-validation-methods

Tim O
  • 731
  • 5
  • 11
3

Found myself trying to solve this problem just now. I've modified Tim O's answer slightly to come up with the following, which provides cleaner output and more information to the errors object that you can then display to the user in the view.

validate :validate_emails

def validate_emails
  emails.each do |email|
    unless email.match(/^([^@\s]+)@((?:[-a-z0-9]+\.)+[a-z]{2,})$/i)
      errors.add(:emails, "#{email} is not a valid email address.")
    end
  end
end
Vickash
  • 1,076
  • 8
  • 8
-4

Here's an example that might help out of the rails api docs: http://apidock.com/rails/ActiveModel/Validations/ClassMethods/validates

The power of the validates method comes when using custom validators and default validators in one call for a given attribute e.g.

class EmailValidator < ActiveModel::EachValidator
  def validate_each(record, attribute, value)
    record.errors[attribute] << (options[:message] || "is not an email") unless
      value =~ /\A([^@\s]+)@((?:[-a-z0-9]+\.)+[a-z]{2,})\z/i
  end
end

class Person
  include ActiveModel::Validations
  attr_accessor :name, :email

  validates :name, :presence => true, :uniqueness => true, :length => { :maximum => 100 }
  validates :email, :presence => true, :email => true
end
slm
  • 15,396
  • 12
  • 109
  • 124
  • To whom ever down voted this answer, care to elaborate on why? My answer is directly out of the official rails documentation on how to validate email addresses. – slm Oct 24 '11 at 14:10
  • Again, it's fine if you want to downvote this answer, but driving by, hitting the down arrow, and then saying nothing does nothing for me the poster of this answer nor anyone else that may come by and wonder what's wrong with this particular answer. This is directly out of the official rails documentation! – slm Feb 28 '12 at 18:27
  • 9
    1) The poster is asking specifically about validation for Mongoid. Barring the fact that some syntax works the same way in both AR and Mongoid, your answer totally ignores this fact. 2) He's asking how to validate EACH ELEMENT of a SINGLE Array field in Mongoid, expecting each element to be a valid email address. You've shown how to validate a string field which can hold just one email address. – Vickash Jul 12 '12 at 05:58