Skip to content
Closed
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
5 changes: 5 additions & 0 deletions lib/mongoid/attributes.rb
Original file line number Diff line number Diff line change
Expand Up @@ -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)
Comment on lines +104 to +108
Copy link

Copilot AI Apr 27, 2026

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

process_raw_attribute writes the demongoized value back into attributes whenever raw is a BSON::Document and the result is not. This is broader than “Hash field BSON::Document → Hash” and can break other field types that legitimately demongoize a BSON document into a non-hash value (e.g. Mongoid::Fields::Localized#demongoize accepts a Hash/BSON::Document of translations but returns a single translation value). In that case this line would overwrite the stored translations hash with a scalar value.

Please restrict the write-back to the intended case (Hash-typed fields only, when legacy_hash_fields is false, and when the demongoized value is a plain Hash).

Suggested change
# 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)
# When demongoize converts a BSON::Document to a plain Hash for a
# Hash field (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.
if field&.type == Hash && !Mongoid.legacy_hash_fields &&
raw.is_a?(BSON::Document) && value.instance_of?(Hash)
attributes[name] = value
end

Copilot uses AI. Check for mistakes.
is_relation = relations.key?(name)
attribute_will_change!(name) if value.resizable? && !is_relation
value
Expand Down
5 changes: 5 additions & 0 deletions lib/mongoid/config.rb
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down
17 changes: 17 additions & 0 deletions lib/mongoid/extensions/hash.rb
Original file line number Diff line number Diff line change
Expand Up @@ -105,6 +105,23 @@ 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.
# 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 unless object.is_a?(BSON::Document) &&
!Mongoid.config.legacy_hash_fields

object.each_with_object({}) { |(k, v), h| h[k] = ::Hash.demongoize(v) }
end

Comment on lines +119 to +124
Copy link

Copilot AI Apr 27, 2026

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Hash.demongoize claims to “recursively” convert nested BSON::Document values, but the current implementation only recurses through hash values and will leave BSON::Document instances that are nested inside arrays unchanged (because arrays are returned as-is). Hash-typed fields can contain arrays of sub-documents, so this can still leak BSON::Document objects into the returned structure when legacy_hash_fields is false.

Consider extending the deep conversion to traverse arrays (and any other container types you want to support), and add a spec that covers a BSON::Document nested inside an array value.

Suggested change
return object unless object.is_a?(BSON::Document) &&
!Mongoid.config.legacy_hash_fields
object.each_with_object({}) { |(k, v), h| h[k] = ::Hash.demongoize(v) }
end
return object if Mongoid.config.legacy_hash_fields
return object unless object.is_a?(BSON::Document)
demongoize_hash_field_value(object)
end
private
def demongoize_hash_field_value(object)
case object
when BSON::Document
object.each_with_object({}) do |(k, v), h|
h[k] = demongoize_hash_field_value(v)
end
when Array
object.map { |value| demongoize_hash_field_value(value) }
else
object
end
end

Copilot uses AI. Check for mistakes.
# Turn the object from the ruby type we deal with to a Mongo friendly
# type.
#
Expand Down
124 changes: 124 additions & 0 deletions perf/benchmark_hash_demongoize.rb
Original file line number Diff line number Diff line change
@@ -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
57 changes: 57 additions & 0 deletions spec/mongoid/changeable_spec.rb
Original file line number Diff line number Diff line change
Expand Up @@ -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
Comment on lines +271 to +274
Copy link

Copilot AI Apr 27, 2026

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

This around block restores Mongoid.config.legacy_hash_fields to true unconditionally. To avoid cross-test leakage if another example/context set a different value, please save the previous config value before setting it to false and restore that saved value in the ensure clause.

Suggested change
Mongoid.config.legacy_hash_fields = false
example.run
ensure
Mongoid.config.legacy_hash_fields = true
previous_legacy_hash_fields = Mongoid.config.legacy_hash_fields
Mongoid.config.legacy_hash_fields = false
example.run
ensure
Mongoid.config.legacy_hash_fields = previous_legacy_hash_fields

Copilot uses AI. Check for mistakes.
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
Expand Down
50 changes: 50 additions & 0 deletions spec/mongoid/extensions/hash_spec.rb
Original file line number Diff line number Diff line change
Expand Up @@ -179,6 +179,56 @@
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
Comment on lines +194 to +197
Copy link

Copilot AI Apr 27, 2026

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

This around block forces Mongoid.config.legacy_hash_fields back to true in the ensure clause, which can leak state if the pre-existing value wasn’t true (e.g. if another spec/context changed it). Please capture the original value before overriding it and restore that value in ensure to keep the spec isolated.

Suggested change
Mongoid.config.legacy_hash_fields = false
example.run
ensure
Mongoid.config.legacy_hash_fields = true
original_legacy_hash_fields = Mongoid.config.legacy_hash_fields
Mongoid.config.legacy_hash_fields = false
example.run
ensure
Mongoid.config.legacy_hash_fields = original_legacy_hash_fields

Copilot uses AI. Check for mistakes.
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' do
let(:plain) { { 'x' => 1, 'y' => 'hello' } }

it 'returns the same object unchanged' do
expect(Hash.demongoize(plain)).to equal(plain)
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
Expand Down
Loading