From 85c21585919359e11c73a04a6f89b80b8642660d Mon Sep 17 00:00:00 2001 From: Jamis Buck Date: Fri, 24 Apr 2026 17:06:33 -0600 Subject: [PATCH 1/4] MONGOID-5684 Add legacy_hash_fields config option --- lib/mongoid/config.rb | 5 +++++ 1 file changed, 5 insertions(+) diff --git a/lib/mongoid/config.rb b/lib/mongoid/config.rb index 98ae824d1b..d0fa3049b8 100644 --- a/lib/mongoid/config.rb +++ b/lib/mongoid/config.rb @@ -78,6 +78,11 @@ module Config # Store BigDecimals as Decimal128s instead of strings in the db. option :map_big_decimal_to_decimal128, default: true + # When true (the default), preserve legacy behavior where Hash-typed fields + # return BSON::Document after loading from the database. Set to false to + # have Hash-typed fields return plain Hash, matching the declared field type. + option :legacy_hash_fields, default: true + # Allow BSON::Decimal128 to be parsed and returned directly in # field values. When BSON 5 is present and this option is set to false # (the default), BSON::Decimal128 values in the database will be returned From 29ca97ec3d8461a8d1e6b691e43c8437d4489056 Mon Sep 17 00:00:00 2001 From: Jamis Buck Date: Fri, 24 Apr 2026 17:10:01 -0600 Subject: [PATCH 2/4] MONGOID-5684 Demongoize BSON::Document to Hash when legacy_hash_fields is false --- lib/mongoid/extensions/hash.rb | 21 +++++++++ spec/mongoid/extensions/hash_spec.rb | 66 ++++++++++++++++++++++++++++ 2 files changed, 87 insertions(+) diff --git a/lib/mongoid/extensions/hash.rb b/lib/mongoid/extensions/hash.rb index 2c9baa4b9e..60278ce911 100644 --- a/lib/mongoid/extensions/hash.rb +++ b/lib/mongoid/extensions/hash.rb @@ -105,6 +105,27 @@ def to_criteria Mongoid.deprecate(self, :to_criteria) module ClassMethods + # Turn the object from a Mongo-friendly type back to the Ruby type. + # When +legacy_hash_fields+ is false, converts BSON::Document to a + # plain Hash recursively so that Hash-typed fields always return Hash. + # + # @param [ Object ] object The object to demongoize. + # + # @return [ Hash | Object | nil ] The demongoized object. + def demongoize(object) + return if object.nil? + return object if Mongoid.config.legacy_hash_fields + + case object + when BSON::Document + object.each_with_object({}) { |(k, v), h| h[k] = ::Hash.demongoize(v) } + when ::Hash + object.transform_values { |v| ::Hash.demongoize(v) } + else + object + end + end + # Turn the object from the ruby type we deal with to a Mongo friendly # type. # diff --git a/spec/mongoid/extensions/hash_spec.rb b/spec/mongoid/extensions/hash_spec.rb index 2904154461..bc576dce67 100644 --- a/spec/mongoid/extensions/hash_spec.rb +++ b/spec/mongoid/extensions/hash_spec.rb @@ -179,6 +179,72 @@ expect(demongoized).to eq(1) end end + + context 'when the object is a BSON::Document and legacy_hash_fields is true' do + let(:doc) { BSON::Document.new('x' => 1, 'y' => 2) } + + it 'returns the BSON::Document unchanged (legacy behavior)' do + expect(Hash.demongoize(doc)).to be_a(BSON::Document) + expect(Hash.demongoize(doc)).to equal(doc) + end + end + + context 'when the object is a BSON::Document and legacy_hash_fields is false' do + around do |example| + Mongoid.config.legacy_hash_fields = false + example.run + ensure + Mongoid.config.legacy_hash_fields = true + end + + let(:doc) { BSON::Document.new('x' => 1, 'y' => 2) } + + it 'returns a plain Hash' do + result = Hash.demongoize(doc) + expect(result).to be_a(Hash) + expect(result).not_to be_a(BSON::Document) + end + + it 'preserves the key/value data' do + result = Hash.demongoize(doc) + expect(result).to eq({ 'x' => 1, 'y' => 2 }) + end + + context 'when the object is a plain Hash with plain values' do + let(:plain) { { 'x' => 1, 'y' => 'hello' } } + + it 'returns an equivalent plain Hash' do + result = Hash.demongoize(plain) + expect(result).to be_a(Hash) + expect(result).not_to be_a(BSON::Document) + expect(result).to eq({ 'x' => 1, 'y' => 'hello' }) + end + end + + context 'when the object is a plain Hash containing a nested BSON::Document' do + let(:hash_with_nested) do + { 'a' => BSON::Document.new('b' => 1) } + end + + it 'recursively converts nested BSON::Documents inside plain Hash' do + result = Hash.demongoize(hash_with_nested) + expect(result['a']).to be_a(Hash) + expect(result['a']).not_to be_a(BSON::Document) + expect(result['a']['b']).to eq(1) + end + end + + context 'when the BSON::Document has nested BSON::Documents' do + let(:nested) { BSON::Document.new('a' => BSON::Document.new('b' => 3)) } + + it 'recursively converts nested BSON::Documents to plain Hash' do + result = Hash.demongoize(nested) + expect(result['a']).to be_a(Hash) + expect(result['a']).not_to be_a(BSON::Document) + expect(result['a']['b']).to eq(3) + end + end + end end describe '.mongoize' do From 101d339e479a24cb0ba1bbd95a032ca3aedbccb9 Mon Sep 17 00:00:00 2001 From: Jamis Buck Date: Fri, 24 Apr 2026 17:21:41 -0600 Subject: [PATCH 3/4] MONGOID-5684 Integration specs for Hash field DB round-trip type behavior Also fix process_raw_attribute to store the demongoized value back into attributes when demongoize returns a new object (e.g. BSON::Document to plain Hash). Without this, in-place mutations on the returned Hash would not be reflected in dirty tracking because the returned object was decoupled from attributes. --- lib/mongoid/attributes.rb | 5 +++ spec/mongoid/changeable_spec.rb | 57 +++++++++++++++++++++++++++++++++ 2 files changed, 62 insertions(+) diff --git a/lib/mongoid/attributes.rb b/lib/mongoid/attributes.rb index 400e3f7856..426576c149 100644 --- a/lib/mongoid/attributes.rb +++ b/lib/mongoid/attributes.rb @@ -101,6 +101,11 @@ def read_attribute(name) # @api private def process_raw_attribute(name, raw, field) value = field ? field.demongoize(raw) : raw + # When demongoize converts a BSON::Document to a plain Hash (i.e. + # legacy_hash_fields is false), store the plain Hash back into + # attributes so that in-place mutations on the returned value are + # visible to dirty tracking. + attributes[name] = value if raw.is_a?(BSON::Document) && !value.is_a?(BSON::Document) is_relation = relations.key?(name) attribute_will_change!(name) if value.resizable? && !is_relation value diff --git a/spec/mongoid/changeable_spec.rb b/spec/mongoid/changeable_spec.rb index 9c012b0423..9d9275ef97 100644 --- a/spec/mongoid/changeable_spec.rb +++ b/spec/mongoid/changeable_spec.rb @@ -251,6 +251,63 @@ end end end + + context 'when the attribute is a hash field and legacy_hash_fields is true (default)' do + let(:person) do + Person.create!(map: { 'location' => 'Home' }) + end + + let(:reloaded) do + Person.find(person.id) + end + + it 'returns a BSON::Document after loading from the database' do + expect(reloaded.map).to be_a(BSON::Document) + end + end + + context 'when the attribute is a hash field and legacy_hash_fields is false' do + around do |example| + Mongoid.config.legacy_hash_fields = false + example.run + ensure + Mongoid.config.legacy_hash_fields = true + end + + let(:person) do + Person.create!(map: { 'location' => 'Home' }) + end + + let(:reloaded) do + Person.find(person.id) + end + + it 'returns a plain Hash after loading from the database' do + expect(reloaded.map).to be_a(Hash) + expect(reloaded.map).not_to be_a(BSON::Document) + end + + it 'returns the correct value' do + expect(reloaded.map).to eq({ 'location' => 'Home' }) + end + + context 'when the field is modified after loading' do + before do + reloaded.map['location'] = 'Work' + end + + it 'field_was returns a plain Hash' do + old_value, = reloaded.map_change + expect(old_value).to be_a(Hash) + expect(old_value).not_to be_a(BSON::Document) + end + + it 'field_was holds the original value' do + old_value, = reloaded.map_change + expect(old_value).to eq({ 'location' => 'Home' }) + end + end + end end context 'when the attribute has not changed from the persisted value' do From 943044c77638aaf8aa00c2e6627cf226395000ba Mon Sep 17 00:00:00 2001 From: Jamis Buck Date: Mon, 27 Apr 2026 14:21:45 -0600 Subject: [PATCH 4/4] MONGOID-5684 Ensure hash-typed fields return Hash --- lib/mongoid/extensions/hash.rb | 14 ++- perf/benchmark_hash_demongoize.rb | 124 +++++++++++++++++++++++++++ spec/mongoid/extensions/hash_spec.rb | 22 +---- 3 files changed, 132 insertions(+), 28 deletions(-) create mode 100644 perf/benchmark_hash_demongoize.rb diff --git a/lib/mongoid/extensions/hash.rb b/lib/mongoid/extensions/hash.rb index 60278ce911..d873cac3ad 100644 --- a/lib/mongoid/extensions/hash.rb +++ b/lib/mongoid/extensions/hash.rb @@ -108,22 +108,18 @@ module ClassMethods # Turn the object from a Mongo-friendly type back to the Ruby type. # When +legacy_hash_fields+ is false, converts BSON::Document to a # plain Hash recursively so that Hash-typed fields always return Hash. + # Plain Hash inputs are returned as-is; conversion is only needed once + # (when the raw BSON::Document is first read from the database). # # @param [ Object ] object The object to demongoize. # # @return [ Hash | Object | nil ] The demongoized object. def demongoize(object) return if object.nil? - return object if Mongoid.config.legacy_hash_fields + return object unless object.is_a?(BSON::Document) && + !Mongoid.config.legacy_hash_fields - case object - when BSON::Document - object.each_with_object({}) { |(k, v), h| h[k] = ::Hash.demongoize(v) } - when ::Hash - object.transform_values { |v| ::Hash.demongoize(v) } - else - object - end + object.each_with_object({}) { |(k, v), h| h[k] = ::Hash.demongoize(v) } end # Turn the object from the ruby type we deal with to a Mongo friendly diff --git a/perf/benchmark_hash_demongoize.rb b/perf/benchmark_hash_demongoize.rb new file mode 100644 index 0000000000..2cf8aae6b9 --- /dev/null +++ b/perf/benchmark_hash_demongoize.rb @@ -0,0 +1,124 @@ +# frozen_string_literal: true + +# Benchmarks Hash.demongoize performance for the three scenarios relevant to +# MONGOID-5684: +# +# legacy - legacy_hash_fields: true (BSON::Document returned as-is, O(1)) +# first read - legacy_hash_fields: false, BSON::Document input (conversion, O(N)) +# subsequent - legacy_hash_fields: false, plain Hash input (returns self, O(1)) +# +# Each scenario uses a separate Benchmark.ips block with the config flag set +# before measurement begins, so the flag is stable during the entire run. +# +# Run with: +# bundle exec ruby perf/benchmark_hash_demongoize.rb + +require 'benchmark/ips' +require 'mongoid' + +# --------------------------------------------------------------------------- +# Test documents +# --------------------------------------------------------------------------- + +EMPTY = BSON::Document.new.freeze + +FLAT = BSON::Document.new( + 'name' => 'Alice', + 'age' => 30, + 'active' => true, + 'score' => 9.5, + 'tag' => 'vip' +).freeze + +# Three levels deep, 10 keys at each level. Leaf values are integers. +NESTED = BSON::Document.new( + 10.times.to_h do |i| + [ + "l1_#{i}", + BSON::Document.new( + 10.times.to_h do |j| + [ + "l2_#{j}", + BSON::Document.new( + 10.times.to_h { |k| [ "l3_#{k}", k ] } + ) + ] + end + ) + ] + end +).freeze + +# Pre-converted plain Hash equivalents for the "subsequent read" scenario. +Mongoid.config.legacy_hash_fields = false +EMPTY_PLAIN = Hash.demongoize(EMPTY) +FLAT_PLAIN = Hash.demongoize(FLAT) +NESTED_PLAIN = Hash.demongoize(NESTED) +Mongoid.config.legacy_hash_fields = true + +# --------------------------------------------------------------------------- +# Helpers +# --------------------------------------------------------------------------- + +CASES = [ + [ 'empty (0 keys)', EMPTY, EMPTY_PLAIN ], + [ 'flat (5 keys)', FLAT, FLAT_PLAIN ], + [ 'nested (3 levels, 10 keys)', NESTED, NESTED_PLAIN ] +].freeze + +def section(title) + puts "\n#{'=' * 60}" + puts title + puts '=' * 60 +end + +# --------------------------------------------------------------------------- +# Scenario 1: legacy behavior (flag = true) +# Config is set once before the block; all three cases share the same flag. +# --------------------------------------------------------------------------- + +section 'Scenario 1: legacy_hash_fields: true (BSON::Document returned as-is)' + +Mongoid.config.legacy_hash_fields = true +Benchmark.ips do |x| + x.config(warmup: 2, time: 5) + CASES.each do |label, bson, _| + x.report(label) { Hash.demongoize(bson) } + end + x.compare! +end + +# --------------------------------------------------------------------------- +# Scenario 2: first read (flag = false, BSON::Document input) +# This is the O(N) path: allocates and populates a new plain Hash. +# --------------------------------------------------------------------------- + +section 'Scenario 2: legacy_hash_fields: false, BSON::Document input (first read)' + +Mongoid.config.legacy_hash_fields = false +Benchmark.ips do |x| + x.config(warmup: 2, time: 5) + CASES.each do |label, bson, _| + x.report(label) { Hash.demongoize(bson) } + end + x.compare! +end + +# --------------------------------------------------------------------------- +# Scenario 3: subsequent reads (flag = false, plain Hash input) +# After the write-back in process_raw_attribute, attributes holds a plain Hash. +# This should be O(1): two type checks, one return. +# --------------------------------------------------------------------------- + +section 'Scenario 3: legacy_hash_fields: false, plain Hash input (subsequent reads)' + +Mongoid.config.legacy_hash_fields = false +Benchmark.ips do |x| + x.config(warmup: 2, time: 5) + CASES.each do |label, _, plain| + x.report(label) { Hash.demongoize(plain) } + end + x.compare! +end + +Mongoid.config.legacy_hash_fields = true diff --git a/spec/mongoid/extensions/hash_spec.rb b/spec/mongoid/extensions/hash_spec.rb index bc576dce67..f4573b0f77 100644 --- a/spec/mongoid/extensions/hash_spec.rb +++ b/spec/mongoid/extensions/hash_spec.rb @@ -210,27 +210,11 @@ expect(result).to eq({ 'x' => 1, 'y' => 2 }) end - context 'when the object is a plain Hash with plain values' do + context 'when the object is a plain Hash' do let(:plain) { { 'x' => 1, 'y' => 'hello' } } - it 'returns an equivalent plain Hash' do - result = Hash.demongoize(plain) - expect(result).to be_a(Hash) - expect(result).not_to be_a(BSON::Document) - expect(result).to eq({ 'x' => 1, 'y' => 'hello' }) - end - end - - context 'when the object is a plain Hash containing a nested BSON::Document' do - let(:hash_with_nested) do - { 'a' => BSON::Document.new('b' => 1) } - end - - it 'recursively converts nested BSON::Documents inside plain Hash' do - result = Hash.demongoize(hash_with_nested) - expect(result['a']).to be_a(Hash) - expect(result['a']).not_to be_a(BSON::Document) - expect(result['a']['b']).to eq(1) + it 'returns the same object unchanged' do + expect(Hash.demongoize(plain)).to equal(plain) end end