Logo Questions Linux Laravel Mysql Ubuntu Git Menu
 

How does reject_if: :all_blank for accepts_nested_attributes_for work when working with doubly nested associations?

I have my model setup as below. Everything works fine except blank part records are allowed even if all part and chapter fields are blank.

class Book < ActiveRecord::Base
  has_many :parts, inverse_of: :book
  accepts_nested_attributes_for :parts, reject_if: :all_blank
end

class Part < ActiveRecord::Base
  belongs_to :book, inverse_of: :parts
  has_many :chapters, inverse_of: :part
  accepts_nested_attributes_for :chapters, reject_if: :all_blank
end

class Chapter < ActiveRecord::Base
  belongs_to :part, inverse_of: :chapters
end

Spelunking the code, :all_blank gets replaced with proc { |attributes| attributes.all? { |key, value| key == '_destroy' || value.blank? } }. So, I use that instead of :all_blank and add in some debugging. Looks like what is happening is the part's chapters attribute is responding to blank? with false because it is an instantiated hash object, even though all it contains is another hash that only contains blank values:

chapters_attributes: !ruby/hash:ActionController::Parameters
  '0': !ruby/hash:ActionController::Parameters
    title: ''
    text: ''

Is it just not meant to work this way?

I've found a workaround:

accepts_nested_attributes_for :parts, reject_if: proc { |attributes|
  attributes.all? do |key, value|
    key == '_destroy' || value.blank? ||
        (value.is_a?(Hash) && value.all? { |key2, value2| value2.all? { |key3, value3| key3 == '_destroy' || value3.blank? } })
  end
}

But I was hoping I was missing a better way to handle this.


Update 1: I tried redefining blank? for Hash but that causes probs.

class Hash
  def blank?
    :empty? || all? { |k,v| v.blank? }
  end
end

Update 2: This makes :all_blank work as I was expecting it to, but it is ugly and not well-tested.

module ActiveRecord::NestedAttributes::ClassMethods
  REJECT_ALL_BLANK_PROC = proc { |attributes| attributes.all? { |k, v| k == '_destroy' || v.valueless? } }
end
class Object
  alias_method :valueless?, :blank?
end
class Hash
  def valueless?
    blank? || all? { |k, v| v.valueless? }
  end
end

Update 3: Doh! Update 1 had a typo in it. This version does seem to work.

class Hash
  def blank?
    empty? || all? { |k,v| v.blank? }
  end
end

Does this have too much potential for unintended consequences to be a viable option? If this is a good option, where in my app should this code live?

like image 598
Joshua Coady Avatar asked Oct 16 '13 22:10

Joshua Coady


2 Answers

When using :all_blank with accepts_nested_attributes_for, it will check each individual attribute to see if it is blank.

# From the api documentation
REJECT_ALL_BLANK_PROC = proc do |attributes|
  attributes.all? { |key, value| key == "_destroy" || value.blank? }
end

http://api.rubyonrails.org/classes/ActiveRecord/NestedAttributes/ClassMethods.html

The attribute of the nested association will be a hash that contains the attributes of the association. The check to see if the attribute is blank will return false because the hash is not empty - it contains a key for each attribute of the association. This behavior will cause the reject_if: :all_blank to return false because of the nested association.

To work around this, you can add your own method to application_record.rb like so:

# Add an instance method to application_record.rb / active_record.rb
def all_blank?(attributes)
  attributes.all? do |key, value|
    key == '_destroy' || value.blank? ||
    value.is_a?(Hash) && all_blank?(value)
  end
end

# Then modify your model book.rb to call that method
accepts_nested_attributes_for :parts, reject_if: :all_blank?
like image 78
Josh Avatar answered Nov 08 '22 06:11

Josh


Methods in previous answers will not work in this case -

book = Book.create({ 
  parts_attributes: { 
    name: '',
    chapters_attributes: {[
      { name: '', _destroy: false}, 
      { id: '', name: '', _destroy: false }
    ]} 
  } 
})

Here we are providing an array of blank values for chapters with either fields which are blank or _destroy. If you need to reject these values too then you can use this method -

 def all_blank?(attributes)
   attributes.all? do |key, value|
     key == '_destroy' || value.blank? ||
     value.is_a?(Hash) && all_blank?(value) ||
     value.is_a?(Array) && value.all? { |val| all_blank?(val) }
   end
 end

Here in addition to previous conditions, we have added a line, which checks if all elements in array are blank then reject it as well.

like image 39
shubhamjuneja Avatar answered Nov 08 '22 07:11

shubhamjuneja