Published on Nov 22, 2019
in category programming
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.
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
.
class Item
include Mongoid::Document
embedded_in :person, inverse_of: :item
field :name
end
class Book < Item
field :authors, type: Array
end
class BoardGame < Item
field :players, type: Integer
end
class Person
include Mongoid::Document
embeds_one :item, inverse_of: :person
accepts_nested_attributes_for :item, allow_destroy: true
end
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"]>
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
.
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
.
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.
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.
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 replaced object will be properly instantiated and assigned to the parent.
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
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:
_type
attribute present and_type
’s value is different than the existing object’s one andThen 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.
Almost forgot, cat photo.