We 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.
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.
Thanks Keith, glad this article could help. Sorry for the loss of 2 hours.
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
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.
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
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
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.
i have the same problem,thank you!
helloqidi,
Glad to help.
Thanks for the workaround!