Mongoid Hash Field Types – Watch Out

Image source: http://www.clker.com/clipart-15325.htmlWe have blogged about Mongoid before. It’s fast, flexible, and supports Hash and Array field types. However useful those types can be, they harbor a lurking monster of deceit.

Modifying a Hash or Array that lives in one of these enumerable data types yields some strange behavior. Saves and updates can be eaten by the hungry beast of a “successful” save-and-reload on the model.

class Foo
  include Mongoid::Document
  field :stats, :type => Hash
end
  
f = Foo.create(:stats => {"bar" => {}})

f.stats["bar"]["baz"] = "qux"
# => <Foo _id: 4da45df77654aa7729000004, stats: {"bar"=>{"baz"=>"qux"}}>
f.save!
# => true

# looks good
f.stats["bar"]
# => {"baz"=>"qux"}

# oh noes, what happened?
f.reload.stats["bar"]
# => {}
  

The Why

Under the covers, Mongoid has a reference to the stats hash. When asked for stats, Mongoid creates a shallow copy via #dup. Since you are receiving a shallow copy, both stats point to the same "bar" hash. When you update a value in the "bar" hash, you’re actually updating it in both places. On save, Mongoid does an equality check to determine if anything has changed. Since both stats hashes point to the "bar", Mongoid thinks the object is clean and thus does not save!

A Workaround

A deep copy ensures that you’re not modifying Mongoid’s copy of the object’s state.

class Object
  def deep_copy
    Marshal.load(Marshal.dump(self))
  end
end
f = Foo.create(:stats => {"bar" => {}})
stats_copy = f.stats.deep_copy
stats_copy["bar"]["baz"] = "qux"

f.stats = stats_copy
f
# => #<Foo _id: 4da45f987654aa7729000005, stats: {"bar"=>{"baz"=>"qux"}}>
f.save!
# => true

# looks good
f.stats["bar"]
# => {"baz"=>"qux"}

# victory...
f.reload.stats["bar"]
# => {"baz"=>"qux"}
  

We decided to use #deep_copy for the few rare cases when this came up in our application. This could be done internally in a patch to Mongoid, but I doubt the patch would be accepted because of performance concerns.

Google Group discussion

Github Issue

Conversation
  • Keith says:

    Arg. I’ve just lost 2 hours trying to figure out why a clone and subsequent save of an document after deleting some embedded docs was not working. Your article makes this ANNOYING behaviour make sense.

  • Shawn Anderson says:

    Thanks Keith, glad this article could help. Sorry for the loss of 2 hours.

  • TS says:

    I tried this in mongoid 2.0.1 and it did not work

    I always get nil as the value of the hash in the collection

  • TS says:

    hmm – this is weird – in a complete new collection with a new record it works.

    However it does not work in my old collection with either new or old records.

  • TS says:

    mea culpa – mea maxima culpa!

    looks like I has a dot in the field name of the hash :-/

    I tried

    f.stats[‘192.168.1.1’] = 1
    f.save

    and it would just silently ignore the hash key, and not save the hash entry

    Apparently dots are not allowed as hash keys
    So my bad.

    this works in mongoid 2.0.1:
    f.stats[‘192_168_1_1’] = 1
    f.save

  • Bruce Kim says:

    I was having trouble with this for awhile and your blog entry put me on the right course. I read through the MongoId source to come up with a reasonable workaround. MongoId doesn’t implement the _will_change! methods for attributes but you can do something similar in your document classes to mark arrays and hashes as dirty and force them to be updated. It’s not perfect since your cheating a bit and saying the old value is nil but it will be a lot faster than doing a deep copy of an array/hash.

    Bruce

    def phone_numbers_will_change!
    @modifications[‘phone_numbers’] = [nil, phone_numbers]
    end

  • Shawn Anderson Shawn Anderson says:

    Thanks Bruce,
    That’s an interesting workaround that I hadn’t thought of. I’m very interested to see how this issue ends up being resolved.

  • helloqidi says:

    i have the same problem,thank you!

  • Shawn Anderson Shawn Anderson says:

    helloqidi,
    Glad to help.

  • Mark says:

    Thanks for the workaround!

  • Comments are closed.