-
Notifications
You must be signed in to change notification settings - Fork 1.4k
MONGOID-5684 Ensure hash-typed fields return Hash #6141
New issue
Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.
By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.
Already on GitHub? Sign in to your account
Changes from all commits
85c2158
29ca97e
101d339
943044c
File filter
Filter by extension
Conversations
Jump to
Diff view
Diff view
There are no files selected for viewing
| Original file line number | Diff line number | Diff line change | ||||||||||||||||||||||||||||||||||||||||||||||||||
|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|
|
|
@@ -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
|
||||||||||||||||||||||||||||||||||||||||||||||||||||
| 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 |
| 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 |
| Original file line number | Diff line number | Diff line change | ||||||||||||||||||
|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|
|
|
@@ -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
|
||||||||||||||||||||
| 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 |
| Original file line number | Diff line number | Diff line change | ||||||||||||||||||
|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|
|
|
@@ -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
|
||||||||||||||||||||
| 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 |
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
process_raw_attributewrites the demongoized value back intoattributeswheneverrawis aBSON::Documentand 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#demongoizeaccepts 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_fieldsis false, and when the demongoized value is a plainHash).