Mongoid - Inheritance: change embedded document's type via nested attributes

How to change the type of an embedded document via nested attributes.
I just released stup, a tool for easily keeping organized daily notes in the terminal. You can find it on GitHub here.

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.

First, I searched for the file that defines the accepts_nested_attributes_for method that we used in the Person class.

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 _type attribute 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
Feedback and comments

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.

Cat