46

I'm having a difficult time understanding how to get Rails to show an explicit error message for a child resource that is failing validation when I render an XML template. Hypothetically, I have the following classes:

class School < ActiveRecord::Base
    has_many :students
    validates_associated :students

    def self.add_student(bad_email)
      s = Student.new(bad_email)
      students << s
    end
end

class Student < ActiveRecord::Base
    belongs_to :school
    validates_format_of :email,
                  :with => /^([^@\s]+)@((?:[-a-z0-9]+\.)+[a-z]{2,})$/i,
                  :message => "You must supply a valid email"
end

Now, in the controller, let's say we want to build a trivial API to allow us to add a new School with a student in it (again, I said, it's a terrible example, but plays its role for the purpose of the question)

class SchoolsController < ApplicationController
    def create
      @school = School.new
      @school.add_student(params[:bad_email])
      respond_to do |format|
          if @school.save
          # some code
          else
            format.xml  { render :xml => @school.errors, :status => :unprocessable_entity }
          end
      end
    end
end

Now the validation is working just fine, things die because the email doesn't match the regex that's set in the validates_format_of method in the Student class. However the output I get is the following:

<?xml version="1.0" encoding="UTF-8"?>
<errors>
  <error>Students is invalid</error>
</errors>

I want the more meaningful error message that I set above with validates_format_of to show up. Meaning, I want it to say:

 <error>You must supply a valid email</error>

What am I doing wrong for that not to show up?

randombits
  • 47,058
  • 76
  • 251
  • 433

9 Answers9

79

Add a validation block in the School model to merge the errors:

class School < ActiveRecord::Base
  has_many :students

  validate do |school|
    school.students.each do |student|
      next if student.valid?
      student.errors.full_messages.each do |msg|
        # you can customize the error message here:
        errors.add_to_base("Student Error: #{msg}")
      end
    end
  end

end

Now @school.errors will contain the correct errors:

format.xml  { render :xml => @school.errors, :status => :unprocessable_entity }

Note:

You don't need a separate method for adding a new student to school, use the following syntax:

school.students.build(:email => email)

Update for Rails 3.0+

errors.add_to_base has been dropped from Rails 3.0 and above and should be replaced with:

errors[:base] << "Student Error: #{msg}"
John Isaiah Carmona
  • 5,260
  • 10
  • 45
  • 79
Harish Shetty
  • 64,083
  • 21
  • 152
  • 198
  • 5
    How can we make this validate method generalized so that I don't have to define it for every association in my model? – mrudult Oct 17 '15 at 21:12
11

Update Rails 5.0.1

You can use Active Record Autosave Association

class School < ActiveRecord::Base
    has_many :students, autosave: true
    validates_associated :students
end

class Student < ActiveRecord::Base
    belongs_to :school
    validates_format_of :email,
                  :with => /^([^@\s]+)@((?:[-a-z0-9]+\.)+[a-z]{2,})$/i,
                  :message => "You must supply a valid email"
end

@school = School.new
@school.build_student(email: 'xyz')
@school.save
@school.errors.full_messages ==> ['You must supply a valid email']

reference: http://api.rubyonrails.org/classes/ActiveRecord/AutosaveAssociation.html

Ashish Agrawal
  • 371
  • 1
  • 4
  • 17
  • 4
    This works in rails 4.2 as well. The `validates_associated :students` is redundant, since autosaved associations validate the children by default, unless `validate: false` is declared. – KenB Apr 26 '17 at 15:04
  • 2
    This validates the associated student but it doesn't discriminate which validations failed when you have multiple validations, it only shows a "Student is not valid." – Arthur Corenzan May 08 '17 at 17:33
9

This is not a public API yet, but Rails 5 stable seems to have ActiveModel::Errors#copy! to merge errors between two models.

user  = User.new(name: "foo", email: nil)
other = User.new(name: nil, email:"foo@bar.com")

user.errors.copy!(other.errors)
user.full_messages #=> [ "name is blank", "email is blank" ] 

Again, this is not officially published yet (I accidentally find this one before monkey-patching Errors class), and I'm not sure it will be.

So it's up to you.

Quv
  • 2,958
  • 4
  • 33
  • 51
  • 1
    Worked well for me. Tks man! I had a class with ActiveModel::Model included and I wanted to included the errors of another associated object. So validates_associated wouldn't work in my case. But your suggestion worked well. kudos – Mauricio Moraes Mar 27 '18 at 17:04
  • There's a public API for this now: https://stackoverflow.com/a/72832405/320737 – James H Jul 01 '22 at 16:49
  • This is the best answer for when you need to manually copy errors between models. – David Hempy Nov 23 '22 at 17:29
1

I'm not sure if this is the best (or a correct) answer...i'm still learning, but I found this to work pretty well. I haven't tested it extensively, but it does seem to work with rails4:

validate do |school|
  school.errors.delete(:students)
  school.students.each do |student|
    next if student.valid?
    school.errors.add(:students, student.errors)
  end
end
Jacob
  • 1,052
  • 8
  • 10
1

I have the same issue. no good answer so far. So I solved it by myself. by replacing association error message with detail error message:

create a concern file models/concerns/association_error_detail_concern.rb:

module AssociationErrorDetailConcern
  extend ActiveSupport::Concern

  included do
    after_validation :replace_association_error_message
  end

  class_methods do
    def association_names
      @association_names ||= self.reflect_on_all_associations.map(&:name)
    end
  end


  def replace_association_error_message
    self.class.association_names.each do |attr|
      next unless errors[attr]
      errors.delete(attr)
      Array.wrap(public_send(attr)).each do |record|
        record.errors.full_messages.each do |message|
          errors.add(attr, message)
        end
      end
    end
  end
end

in your model:

class School < ApplicationRecord
  include AssociationErrorDetailConcern
  has_many :students
  ...
end

then you will get you must supply a valid email error message on students attribute of school record. instead of useless message is invalid

Yi Feng Xie
  • 4,378
  • 1
  • 26
  • 29
0

Here's an example that could stand some DRYing:

def join_model_and_association_errors!(model)
  klass = model.class

  has_manys = klass.reflect_on_all_associations(:has_many)
  has_ones = klass.reflect_on_all_associations(:has_one)
  belong_tos = klass.reflect_on_all_associations(:belongs_to)
  habtms = klass.reflect_on_all_associations(:has_and_belongs_to_many)

  collection_associations = [has_manys, habtms].flatten
  instance_associations = [has_ones, belong_tos].flatten

  (collection_associations + instance_associations).each do |association|
    model.errors.delete(association.name)
  end

  collection_associations.each do |association|
    model.send(association.name).each do |child|
      next if child.valid?
      errors = child.errors.full_messages
      model.errors[:base] << "#{association.class_name} Invalid: #{errors.to_sentence}"
    end
  end

  instance_associations.each do |association|
    next unless child = model.send(association.name)
    next if child.valid?
    errors = child.errors.full_messages
    model.errors[:base] << "#{association.class_name} Invalid: #{errors.to_sentence}"
  end

  model.errors
end
Chris Cashwell
  • 22,308
  • 13
  • 63
  • 94
0

You should use following in the rhtml.

<%= error_messages_for :school, :student %>

To skip "Students is invalid" message use following in the student.rb

  def after_validation
    # Skip errors that won't be useful to the end user
    filtered_errors = self.errors.reject{ |err| %w{ student}.include?(err.first) }
    self.errors.clear
    filtered_errors.each { |err| self.errors.add(*err) }
  end

EDITED

Sorry after_validation must be in a school.rb
Salil
  • 46,566
  • 21
  • 122
  • 156
  • What's the best way to do this in an XML template? Right now I don't have an XML view, I just let render handle it? – randombits Apr 21 '10 at 13:39
0

I see a problem in the posted code. add_student is a class method of class School, so self will point to the class object School instead of an instance object of class School. The line students << s will not add the record s to the record school because of this.

I don't know if this is causing your error message problem, but I think this will keep the code from working properly.

Fred
  • 8,582
  • 1
  • 21
  • 27
  • It gets linked and stored into the database as it should. I'm intentionally failing validation by passing a bad email as the problem originally suggests. If I pass a proper email that passes the regex, then I don't get an error. The point here is to *intentionally* fail, and get the appropriate message into the view. – randombits Apr 21 '10 at 20:34
  • I understand you are deliberately failing the example. I don't think you understand my point. Why have you made the add_student method a class method? In Ruby, self points to the class object in class methods, so the message add_student, while being sent to the instance object, executes with self pointing to the class object rather than the instance object. Perhaps ActiveRecord manages to do the right thing despite this, but unless I'm missing something, the method should not be a class method. I would be grateful if you could tell me what I'm missing. – Fred Apr 22 '10 at 12:50
  • OK, I understand now. The add_student method cannot be a class method; that was a typo in your example. If you send a class method message to an instance object, you get NoMethodError. Rails doesn't recover from calling a class method on an instance object, at least in this case. The code you gave produces a NoMethodError in SchoolsController#create when I tried it out. Ruby instance objects do not see class methods because class methods are singleton methods for the class object. That's what I didn't understand, and now I do. Learn a thing a day, drive ignorance away! – Fred Apr 22 '10 at 13:59
0

The original question isn't specifically about showing errors on associations, though it's implied by the posted example. I came here looking for a way to flatten a tree of ActiveModel objects' errors into its root node.

For this more general case, as of Rails 5.2.3 ActiveModel::Errors has a merge!(other) method, where other is an instance which mixes in ActiveModel::Errors (eg. an ActiveRecord or ActiveModel instance).

It merges the errors from other, with each Error wrapped as NestedError.

class Car
  include ActiveModel::Model
  include ActiveModel::Attributes

  validate :validate_attributes

  attribute :engine
  attribute :transmission

  def validate_attributes
    attributes.each do |name, obj|
      next if obj.valid?
      errors.merge!(obj)
    end
  end

end

class Engine
  include ActiveModel::Model
  include ActiveModel::Attributes

  validates :brand, presence: true
  attribute :brand
end

class Transmission
  include ActiveModel::Model
  include ActiveModel::Attributes

  validates :type, presence: true
  attribute :type
end


c = Car.new(engine: Engine.new, transmission: Transmission.new)
c.valid?
c.errors.details
=> {:brand=>[{:error=>:blank}], :type=>[{:error=>:blank}]}
James H
  • 1,824
  • 22
  • 23