I recently faced some issues when trying to update a nested document’s type via nested attributes using the mongoid gem. Bare with me if this sentence doesn’t make any sense yet.
Use case
Suppose we have a document named Person that embeds a document that can be any of the types Book or BoardGame which inherit from the class Item.
Item
class Item
include Mongoid::Document
embedded_in :person, inverse_of: :item
field :name
end
Book
class Book < Item
field :authors, type: Array
end
BoardGame
class BoardGame < Item
field :players, type: Integer
end
Person
class Person
include Mongoid::Document
embeds_one :item, inverse_of: :person
accepts_nested_attributes_for :item, allow_destroy: true
end
The problems
To simplify the post I won’t go with the scenario of building the forms and the controller actions to demonstrate the problems but instead I will simulate the behavior in the rails console.
Let’s create a Person with a Book as an Item.
>> person = Person.create!({
item_attributes: {
_type: "Book",
name: "Jitterbug Perfume",
authors: ["Tom Robbins"]
}
})
=> #<Person _id: 5dd7b01ec2889856ab8a619f, >
>> person.item
=> #<Book _id: 5dd7ae76c2889856ab8a619e, name: "Jitterbug Perfume", _type: "Book", authors: ["Tom Robbins"]>
Failing attribute assignments
Let’s try to change the person’s item from Book to BoardGame with 5 players.
>> person.update_attributes({
item_attributes: {
_type: "BoardGame",
name: "Ticket to Ride",
players: 5
}
})
Traceback (most recent call last):
1: from (irb):9
Mongoid::Errors::UnknownAttribute ()
message:
Attempted to set a value for 'players' which is not allowed on the model Book.
summary:
Without including Mongoid::Attributes::Dynamic in your model and the attribute does not already exist in the attributes hash, attempting to call Book#players= for it is not allowed. This is also triggered by passing the attribute to any method that accepts an attributes hash, and is raised instead of getting a NoMethodError.
resolution:
You can include Mongoid::Attributes::Dynamic if you expect to be writing values for undefined fields often.
This didn’t work, the assignment of the nested attributes item_attributes as you can see from the error message were applied in the model Book.
The fact that we changed the _type attribute of the document doesn’t mean that the object in memory also was magically transformed from a Book to a BoardGame.
Document attribute leftovers
Let’s try to change the person’s item from Book to BoardGame but this time without the players to see if this fixes the issue.
>> person.update_attributes({
item_attributes: {
_type: "BoardGame",
name: "Ticket to Ride"
}
})
=> true
>> person.item
=> #<Book _id: 5dd7b270c2889858a08a619e, name: "Ticket to Ride", _type: "BoardGame", authors: ["Tom Robbins"]>
This seems to have worked even though the result of the person.item accessor gave a Book document with _type of a BoardGame and with an attribute authors present.
Let’s reload to see if the document is going to be properly instantiated.
>> person.reload.item
=> #<BoardGame _id: 5dd7b270c2889858a08a619e, name: "Ticket to Ride", _type: "BoardGame", players: nil>
Seems to be fine but what is going on with the actual document attributes?
>> person.item.attributes
=> {"_id"=>BSON::ObjectId('5dd7b270c2889858a08a619e'), "_type"=>"BoardGame", "name"=>"Ticket to Ride", "authors"=>["Tom Robbins"]}
As you can see, the authors attribute (that we had initially set) hasn’t been removed when we changed the type of the Item from Book to BoardGame.
Wrong validators
Let’s change the BoardGame document so that it validates the presence of players
class BoardGame < Item
field :players, type: Integer
validates :players, presence: true
end
Our person now has an item of type BoardGame. We will now try to change it to a Book
>> person.attributes = {
item_attributes: {
_type: "Book",
name: "Jitterbug Perfume"
}
}
=> {:item_attributes=>{:_type=>"Book", :name=>"Jitterbug Perfume"}}
>> person.valid?
=> false
>> person.errors.full_messages
=> ["Item is invalid"]
>> person.item.errors.full_messages
=> ["Players can't be blank"]
As described in the previous section, the fact that the _type attribute changed doesn’t mean that the actual object in memory changed accordingly. Upon save of the parent document, the item is still an instance of the previous _type which was BoadGame thus the validations that take place are not the ones defined in the new Book class but those defined in the BoardGame.
This also means that if we had validations in the Book class too, they wouldn’t execute in this scenario as well.
Investigation
I tried to google the issue (I couldn’t even construct a proper query) to see how others dealt with this but I didn’t find anything helpful and I decided to dive in the source of mongoid in order to locate the code that deals with the nested assignments and possibly create a monkey patch.
Found in mongoid/attributes/nested.rb
def accepts_nested_attributes_for(*args)
options = args.extract_options!.dup
options[:autosave] = true if options[:autosave].nil?
options[:reject_if] = REJECT_ALL_BLANK_PROC if options[:reject_if] == :all_blank
args.each do |name|
meth = "#{name}_attributes="
self.nested_attributes["#{name}_attributes"] = meth
association = relations[name.to_s]
raise Errors::NestedAttributesMetadataNotFound.new(self, name) unless association
autosave_nested_attributes(association) if options[:autosave]
re_define_method(meth) do |attrs|
_assigning do
if association.polymorphic? and association.inverse_type
options = options.merge!(:class_name => self.send(association.inverse_type))
end
association.nested_builder(attrs, options).build(self)
end
end
end
end
The line that is mostly related to our issue from the above snippet is this:
association.nested_builder(attrs, options).build(self)
which is executed upon attribute assignment.
Since the item association defined in the Person document is of type embeds_one, I located its code to find the nested_builder method.
Found it at mongoid/association/embedded/embeds_one.rb
def nested_builder(attributes, options)
Nested::One.new(self, attributes, options)
end
Ok, now we have to see what is going on inside the #build method of the mongoid/association/nested/one.rb file.
def build(parent)
return if reject?(parent, attributes)
@existing = parent.send(association.name)
if update?
attributes.delete_id
existing.assign_attributes(attributes)
elsif replace?
parent.send(association.setter, Factory.build(@class_name, attributes))
elsif delete?
parent.send(association.setter, nil)
end
end
def replace?
!existing && !destroyable? && !attributes.blank?
end
def update?
existing && !destroyable? && acceptable_id?
end
The part that is related to the behavior we want to alter is the one inside the if update? .. elsif replace? block:
...
if update?
attributes.delete_id
existing.assign_attributes(attributes)
elsif replace?
parent.send(association.setter, Factory.build(@class_name, attributes))
...
This is where the attribute assignment in the existing embedded document takes place.
Desired behavior
Since we are talking about embedded documents, when changing the type instead of updating the existing document’s attributes we can easily replace it at once with a new instance of the proper class.
From that point on, we won’t have to deal with
- the invalid attribute assignments
- removing the old attributes that are not present in the new document type
- fixing the usage of wrong validators.
The replaced object will be properly instantiated and assigned to the parent.
Solution
To change to the desired aforementioned behavior, I patched the Mongoid::Association::Nested::One class.
I created a module (I suggest you read this great article about monkey patching without making a mess) under lib/core_extensions/mongoid/association/nested/one/embedded_one_change_type.rb with the following contents:
module CoreExtensions
module Mongoid
module Association
module Nested
module One
module EmbeddedOneChangeType
attr_accessor :changing_type
def build(parent)
return if reject?(parent, attributes)
@existing = parent.send(association.name)
# Retrieve the new class
new_class = attributes.dig(:_type)&.constantize
# Resolve whether we should be changing type or not
@changing_type = new_class && @existing.class != new_class && association.embedded?
if update?
attributes.delete_id
existing.assign_attributes(attributes)
elsif replace?
parent.send(association.setter, ::Mongoid::Factory.build(@class_name, attributes))
elsif delete?
parent.send(association.setter, nil)
end
end
def replace?
# Replace if the association hasn't been set or if we are changing type.
(!existing || changing_type) && !destroyable? && !attributes.blank?
end
def update?
# Don't update the document if we are changing type
existing && !changing_type && !destroyable? && acceptable_id?
end
end
end
end
end
end
end
and inside an initializer I patched the proper mongoid class with:
Mongoid::Association::Nested::One.prepend CoreExtensions::Mongoid::Association::Nested::One::EmbeddedOneChangeType
Explanation
We override the build method in order to additionally resolve if we are changing type in an embedded association or not:
@changing_type = new_class && @existing.class != new_class && association.embedded?
We are changing type if:
- there’s a
_typeattribute present and - the
_type’s value is different than the existing object’s one and - the association is embedded
Then we alter the methods that decide whether a document should be updated or replaced, so that they take in consideration if we are @changing_type (as calculated above) or not:
def update?
# Don't update the document if we are changing type
existing && !changing_type && !destroyable? && acceptable_id?
end
and
def replace?
# Replace if the association hasn't been set or if we are changing type.
(!existing || changing_type) && !destroyable? && !attributes.blank?
end
I am not very fond of monkey patching but this was my quick workaround. If you believe that the desired behavior as described here (for the `embedded_one` associations) is invalid or if you deal with this problem in any other way, I'd be glad to here about it.
For suggestions, feedback, comments, typos etc. please use this issue on GitHub.
Thanks for visiting!
Almost forgot, cat photo.

(