Breaking Mongoid Inheritance

AuthorMáximo Mussini
·5 min read

By default, Mongoid will use single-collection inheritance when extending a Ruby class, by storing a _type attribute in every document in the collection that contains the concrete class name, and using it to instantiate the object properly when fetching a document from the database.

In addition, it will handle the hierarchy in queries, by allowing to query the parent class to return documents from any subclass, or query a specific subclass to fetch only documents of that specific type. In order to do this efficiently, Mongoid will check for existing indexes that contain _type as a prefix, or add a { _type: 1 } index.

As a consequence of the approach:

  • Storage size increases since we need to store an additional attribute on every document. The smaller the document, the bigger the impact of this extra field.
  • For large collections, adding a _type index or prefix it to existing ones to create compound indexes could be a concern, since large indexes might not fit in memory, which would quickly degrade the performance.

Bah, trade-offs. It's still awesome 😏

While this behaviour is usually desirable, there are some scenarios where it's suitable to use inheritance in Ruby but it doesn't make sense to store different classes of the hierarchy in the same collection.

In particular, if subclasses will always be queried independently, we can store each type in a different collection, which will improve the performance because it:

  • Doesn't require additional indexes.
  • Doesn't require extra information in each document.
  • Provides a natural way to partition the data.

Easy. Just use mixins to share code between the subclasses, Mongoid will store them in separate collections 😌


An example (more like "A Very Contrived Example" 😄)

Let's imagine that we have a drawing app, where you can draw many triangles on a canvas, and need to choose between three different drawing modes: regular, equilateral, or isosceles.

class Triangle
  include Mongoid::Document
  ...
end

class IsoscelesTriangle < Triangle
  validate_two_sides_are_equal
end

class EquilateralTriangle < Triangle
  validate_all_sides_are_equal
end

We can take advantage of this restriction and store each type of triangle in a separate collection, which will prevent the database from scanning more documents than necessary to execute our queries.

This will be more efficient than adding an extra _type attribute and index, which is the default behaviour provided by Mongoid when inheriting a model. If we want to make this work, we will need to avoid Mongoid's single-collection inheritance.

Mixins

Using mixins to share the code is a nice way to get the job done, but in this case it falls short because Triangle (the base class) is not abstract—turning it into a module wouldn't allow us to instantiate it. We can deal with this by creating a module that contains the code that we want to reuse.

We shall name it Trianglable. Hmm, sounds weird, let's go with Trilateral. Maybe BaseTriangle? Triangleness? Damn, names are tough 😫

module AbstractTriangle
  include Mongoid::Document
  ...
end

class Triangle
  include AbstractTriangle
end

class IsoscelesTriangle
  include AbstractTriangle
  validate_two_sides_are_equal
end

class EquilateralTriangle
  include AbstractTriangle
  validate_all_sides_are_equal
end

Much better 😐


Using Inheritance

In cases like this I would like to start with inheritance, which can make the code easier to follow, and move to the mixin approach or composition as the requirements change and some of the behaviour or logic in the base class should no longer be shared with the subclasses.

When facing a similar situation recently, I decided to take a look at Mongoid internals and find out if it was viable to prevent the unwanted STI behaviour. Ideally, we would get standard Ruby inheritance, without the subclass being handled differently by Mongoid.

The first thing to do, was to look for an inherited hook in one of the many modules inside the library, which happened to be in Mongoid::Traversable. Unfortunately, there's a lot going on in that method; Mongoid doesn't make it easy to extend or modify its functionality in a clean way.

Feeling determined, I chose to hack my way into a solution. The result is the module below—hacky at best, more likely a problem waiting for the next Mongoid update to blow up 🙉

module Mongoid

  # Public: Allows to use inheritance to reuse logic, without using Single-
  # Collection Inheritance, storing the model and superclass in different
  # collections.
  module NoHeritage
    extend ActiveSupport::Concern

    included do
      # Internal: Preserve the default storage options instead of storing in
      # the same collection than the superclass.
      delegate :storage_options, to: :class
    end

    module ClassMethods
      # Internal: Prevent adding _type in query selectors, and adding an index
      # for _type.
      def hereditary?
        false
      end

      # Internal: Prevent Mongoid from defining a _type getter and setter.
      def field(name, options = {})
        super unless name.to_sym == :_type
      end

      # Internal: Preserve the default storage options instead of storing in
      # the same collection than the superclass.
      def inherited(subclass)
        super

        def subclass.storage_options
          @storage_options ||= storage_options_defaults
        end
      end
    end
  end
end

All things considered, it provided a nice balance between sharing code, keeping the storage and index size down, and maintaining a straightforward structure in the code.

¯\(ツ)