-
Type:
Bug
-
Resolution: Fixed
-
Priority:
Unknown
-
Affects Version/s: 9.1.0
-
Component/s: Validations
-
None
-
None
-
Fully Compatible
-
Ruby Drivers
-
Not Needed
-
None
-
None
-
None
-
None
-
None
-
None
When a document referenced via a has_many validate: true is destroyed, then the parent is later saved. before_validation callbacks end up being fired on the destroyed child. If they try to mutate the document, they get an error about modifying a frozen hash.
I would expect that destroyed children should not be validated, even if they're hanging around in memory.
Reproduction:
require 'mongoid' Mongoid.configure do |c| c.clients.default = { hosts: ['localhost:27017'], database: 'mongoid_assoc_validator_repro' } end puts "Mongoid version: #{Mongoid::VERSION}" class Parent include Mongoid::Document has_many :children, validate: true end class Child include Mongoid::Document field :note, type: String belongs_to :parent before_validation :touch_note def touch_note self.note = "validated at #{Time.now.to_f}" # writes into the attributes hash end end Parent.delete_all Child.delete_all parent = Parent.create! child = parent.children.create! # created through the association, so it's held in the loaded proxy # A child is destroyed. In V9, `Persistable::Deletable#prepare_delete` `freeze`s # the document's attributes hash before flipping `destroyed = true` - and the # destroyed child is still sitting inside the parent's loaded children proxy. child.destroy puts "" puts "ruby-side: child.destroyed? = #{child.destroyed?}" puts "ruby-side: child.attributes.frozen? = #{child.attributes.frozen?}" puts "ruby-side: still in parent's proxy? = #{parent.children.any? { |c| c.equal?(child) }}" # Validating (or saving) the parent runs `validates_associated :children`, which # (V9, lazily) walks the in-memory proxy and calls `.valid?` on each element - # including the destroyed, frozen child. Its before_validation then tries to # write `note`, mutating the frozen attributes hash. begin parent.valid? puts "result: parent.valid? raised = (nothing)" rescue => e puts "result: parent.valid? raised = #{e.class}: #{e.message}" end # Expected: # Validating a destroyed document is never useful (it can't be persisted # again), so AssociatedValidator should skip it; parent.valid? returns true. # Actual on mongoid master / 9.1.0: # FrozenError: can't modify frozen Hash / BSON::Document # because AssociatedValidator calls `.valid?` on the frozen destroyed child, # and its mutating before_validation callback writes to the frozen attributes. # # Fix: AssociatedValidator#validate_association should skip destroyed documents. # Its per-value guard is currently # value && !value.flagged_for_destroy? && (!value.persisted? || value.changed?) # Adding `!value.destroyed?` resolves it (a destroyed doc reports # persisted? == false and flagged_for_destroy? == false, so it slips through).
Output
Mongoid version: 9.1.0 ruby-side: child.destroyed? = true ruby-side: child.attributes.frozen? = true ruby-side: still in parent's proxy? = true result: parent.valid? raised = FrozenError: can't modify frozen Hash: {"_id" => BSON::ObjectId('6a1685d9ff90ba5ca8698b14'), "parent_id" => BSON::ObjectId('6a1685d9ff90ba5ca8698b13'), "note" => "validated at 1779860953.9466271"}