From f46ec49fd93e39bb3b25d18a8708e35f391fea24 Mon Sep 17 00:00:00 2001 From: johnnyshields <27655+johnnyshields@users.noreply.github.com> Date: Mon, 5 May 2025 20:17:23 +0900 Subject: [PATCH 1/6] Fix various type-confusion problems between BSON::Document and its parent class Hash: 1. #to_h does not convert nested hashes. (See RUBY-3663) 2. When we call many of the Hash methods which it overrides (especially non-bang methods), they return Hash instances rather than BSON::Document. (Examples: .try_convert, #select, #reject, #invert) 3. Some of the bang (!) methods `#transform_keys!` and `#transform_values!` can be used to transform BSON::Documents into a disallowed, state, e.g. with Symbol keys or Hash values (nested values should always be BSON document). 4. `#deep_symbolize_keys` (non-bang) plainly doesn't work; it should return a Hash with deeply symbolized keys, so all nested BSON::Documents need to be converted to Hash. In a similar vein, #symbolize_keys should return a deep-converted Hash, but with only the first-level Hash's keys symbolized. (It does not make sense to mix Hash with nested BSON::Document, you're gonna have a bad time.) 5. For methods that select keys, such as #key?, #delete, #slice etc. should always consistently be calling #convert_key on the key args. Today this is done inconsistently, for example, #key?, #delete, #dig all convert the keys, but #values_at, #fetch_values, #store, #without, #assoc do not. 6. Some needed aliases are missing. Also #value? is incorrectly aliases as #value (without question mark) which doesn't exist on Hash (coding mistake) 7. Minor point, but in the code, the method definiton of #dig is wrapped in instance_methods.include?(:dig). This is no longer needed, since dig was added in Ruby 2.5 and the minimum Ruby version of BSON Ruby is 2.6. --- lib/bson/document.rb | 278 +++- spec/bson/document_as_spec.rb | 996 ++++++++++++++- spec/bson/document_native_spec.rb | 1949 +++++++++++++++++++++++++++++ spec/bson/document_spec.rb | 69 +- 4 files changed, 3182 insertions(+), 110 deletions(-) create mode 100644 spec/bson/document_native_spec.rb diff --git a/lib/bson/document.rb b/lib/bson/document.rb index f46cab549..863f8e7eb 100644 --- a/lib/bson/document.rb +++ b/lib/bson/document.rb @@ -36,6 +36,20 @@ module BSON # @since 2.0.0 class Document < ::Hash + class << self + # Attempts to convert the provided object to a BSON::Document. + # + # @param [ Object ] object The object to try to convert. + # + # @return [ BSON::Document, nil ] The converted document or nil if it cannot be converted. + def try_convert(object) + return object if object.is_a?(BSON::Document) + + hash = super + BSON::Document.new(hash) if hash + end + end + # Get a value from the document for the provided key. Can use string or # symbol access, with string access being the faster of the two. # @@ -60,7 +74,7 @@ class Document < ::Hash # @example Get an element for the key by symbol with a block default. # document.fetch(:field) { |key| key.upcase } # - # @param [ String, Symbol ] key The key to look up. + # @param [ Object ] key The key to look up. # @param [ Object ] default Returned value if key does not exist. # @yield [key] Block returning default value for the given key. # @@ -81,7 +95,7 @@ def fetch(key, *args, &block) # @example Get an element for the key by symbol. # document[:field] # - # @param [ String, Symbol ] key The key to look up. + # @param [ Object ] key The key to look up. # # @return [ Object ] The found value, or nil if none found. # @@ -106,8 +120,8 @@ def [](key) # # Note that due to this conversion, the object that is stored in the # receiver Document may be different from the object supplied as the - # right hand side of the assignment. In Ruby, the result of assignment - # is the right hand side, not the return value of []= method. + # right-hand side of the assignment. In Ruby, the result of assignment + # is the right-hand side, not the return value of []= method. # Because of this, modifying the result of assignment generally does not # work as intended: # @@ -137,7 +151,7 @@ def [](key) # @example Set a value on the document. # document[:test] = "value" # - # @param [ String, Symbol ] key The key to update. + # @param [ Object ] key The key to update. # @param [ Object ] value The value to update. # # @return [ Object ] The updated value. @@ -147,7 +161,9 @@ def []=(key, value) super(convert_key(key), convert_value(value)) end - # Returns true if the given key is present in the document. Will normalize + alias :store :[]= + + # Returns true if the given key is present in the document. Will normalize # symbol keys into strings. # # @example Test if a key exists using a symbol @@ -155,7 +171,7 @@ def []=(key, value) # # @param [ Object ] key The key to check for. # - # @return [ true, false] + # @return [ true, false ] Whether the key exists in the document. # # @since 4.0.0 def has_key?(key) @@ -166,22 +182,60 @@ def has_key?(key) alias :key? :has_key? alias :member? :has_key? - # Returns true if the given value is present in the document. Will normalize + # Returns true if the given value is present in the document. Will normalize # symbols into strings. # # @example Test if a key exists using a symbol # document.has_value?(:test) # - # @param [ Object ] value THe value to check for. + # @param [ Object ] value The value to check for. # - # @return [ true, false] + # @return [ true, false ] Whether the value exists in the document. # # @since 4.0.0 def has_value?(value) super(convert_value(value)) end - alias :value :has_value? + alias :value? :has_value? + + # Gets the values for the given keys. + # + # @param [ Array ] keys The keys to retrieve values for. + # + # @return [ Array ] The values for the given keys. + def values_at(*keys) + keys.map { |key| self[key] } + end + + # Fetches the values for the given keys. + # + # @param [ Array ] keys The keys to fetch values for. + # @yield [ key ] A block to execute when a key is not found. + # + # @return [ Array ] The values for the given keys. + # + # @raise [ KeyError ] If a key is not found and no block is given. + def fetch_values(*keys, &block) + keys.map do |key| + if block_given? && !key?(key) + yield(convert_key(key)) + else + fetch(key) + end + end + end + + # Searches for a key-value pair with the given key and returns + # the first matching pair found as a two-element array. + # + # @param [ Object ] key The key to search for. + # + # @return [ Array, nil ] A [key, value] pair, or nil if not found. + def assoc(key) + pair = super(convert_key(key)) + pair ? [pair[0], pair[1]] : nil + end # Deletes the key-value pair and returns the value from the document # whose key is equal to key. @@ -189,12 +243,10 @@ def has_value?(value) # block is given and the key is not found, pass in the key and return the # result of block. # - # @example Delete a key-value pair - # document.delete(:test) - # # @param [ Object ] key The key of the key-value pair to delete. + # @yield [ key ] Optional block to execute when key is not found. # - # @return [ Object ] + # @return [ Object ] The value that was deleted or the default result. # # @since 4.0.0 def delete(key, &block) @@ -218,10 +270,8 @@ def initialize(elements = nil) # Merge this document with another document, returning a new document in # the process. # - # @example Merge with another document. - # document.merge(name: "Bob") - # # @param [ BSON::Document, Hash ] other The document/hash to merge with. + # @yield [ key, old_value, new_value ] Optional block for resolving conflicts. # # @return [ BSON::Document ] The result of the merge. # @@ -233,10 +283,8 @@ def merge(other, &block) # Merge this document with another document, returning the same document in # the process. # - # @example Merge with another document. - # document.merge(name: "Bob") - # # @param [ BSON::Document, Hash ] other The document/hash to merge with. + # @yield [ key, old_value, new_value ] Optional block for resolving conflicts. # # @return [ BSON::Document ] The result of the merge. # @@ -251,37 +299,28 @@ def merge!(other) alias :update :merge! - if instance_methods.include?(:dig) - # Retrieves the value object corresponding to the each key objects repeatedly. - # Will normalize symbol keys into strings. - # - # @example Get value from nested sub-documents, handling missing levels. - # document # => { :key1 => { "key2" => "value"}} - # document.dig(:key1, :key2) # => "value" - # document.dig("key1", "key2") # => "value" - # document.dig("foo", "key2") # => nil - # - # @param [ Array ] *keys Keys, which constitute a "path" to the nested value. - # - # @return [ Object, NilClass ] The requested value or nil. - # - # @since 3.0.0 - def dig(*keys) - super(*keys.map{|key| convert_key(key)}) - end + # Retrieves the value object corresponding to the each key objects repeatedly. + # Will normalize symbol keys into strings. + # + # @example Get value from nested sub-documents, handling missing levels. + # document # => { :key1 => { "key2" => "value"}} + # document.dig(:key1, :key2) # => "value" + # document.dig("key1", "key2") # => "value" + # document.dig("foo", "key2") # => nil + # + # @param [ Array ] keys Keys which constitute a path to the nested value. + # + # @return [ Object, NilClass ] The requested value or nil. + # + # @since 3.0.0 + def dig(*keys) + super(*keys.map { |key| convert_key(key) }) end # Slices a document to include only the given keys. # Will normalize symbol keys into strings. - # (this method is backported from ActiveSupport::Hash) # - # @example Get a document/hash with only the `name` and `age` fields present - # document # => { _id: , :name => "John", :age => 30, :location => "Earth" } - # document.slice(:name, 'age') # => { "name": "John", "age" => 30 } - # document.slice('name') # => { "name" => "John" } - # document.slice(:foo) # => {} - # - # @param [ Array ] *keys Keys, that will be kept in the resulting document + # @param [ Array ] keys Keys that will be kept in the resulting document # # @return [ BSON::Document ] The document with only the selected keys # @@ -299,11 +338,7 @@ def slice(*keys) # # The keys to be removed can be specified as either strings or symbols. # - # @example Get a document/hash with only the `name` and `age` fields removed - # document # => { _id: , :name => 'John', :age => 30, :location => 'Earth' } - # document.except(:name, 'age') # => { _id: , location: 'Earth' } - # - # @param [ Array ] *keys Keys, that will be removed in the resulting document + # @param [ Array ] keys Keys that will be removed in the resulting document. # # @return [ BSON::Document ] The document with the specified keys removed. # @@ -312,13 +347,140 @@ def slice(*keys) # its version of #except which doesn't work for BSON::Document which # causes problems if ActiveSupport is loaded after bson-ruby is. def except(*keys) - copy = dup - keys.each {|key| copy.delete(key)} - copy + dup.tap do |doc| + keys.each { |key| doc.delete(key) } + end + end + + alias :without :except + + # Recursively converts the document and all nested documents to a hash. + # + # Accepts an optional block, which is applied to the newly converted hash. + # This is done to mimic the Ruby kernel object behavior of #to_h. + # + # @yield [ key, value ] Optional block for transforming the hash. + # + # @return [ Hash ] A new hash object, containing nested hashes if applicable. + def to_h(&block) + hash = super do |key, value| + [key, value.is_a?(self.class) ? value.to_h : value] + end + block_given? ? hash.send(:to_h, &block) : hash + end + + alias :to_hash :to_h + + # Inverts the document by using values as keys and vice versa. + # + # @return [ BSON::Document ] A new document with keys and values switched. + def invert + self.class.new(super) + end + + # Returns a new document containing key-value pairs for which the block returns true. + # + # @yield [ key, value ] Each key-value pair in the document. + # + # @return [ BSON::Document ] A new document with matching pairs, or an Enumerator if no block given. + def select(&block) + return enum_for(:select) unless block_given? + + dup.tap { |doc| doc.select!(&block) } + end + + alias :filter :select + + # Returns a new document excluding pairs for which the block returns true. + # + # @yield [ key, value ] Each key-value pair in the document. + # + # @return [ BSON::Document ] A new document without matching pairs, or an Enumerator if no block given. + def reject(&block) + return enum_for(:reject) unless block_given? + + dup.tap { |doc| doc.reject!(&block) } + end + + # Transforms all keys in the document using the given block. + # + # @yield [ key ] Each key in the document. + # + # @return [ BSON::Document ] A new document with transformed keys, or an Enumerator if no block given. + def transform_keys(&block) + return enum_for(:transform_keys) unless block_given? + + dup.transform_keys!(&block) + end + + # Transforms all keys in the document in place using the given block. + # + # @yield [ key ] Each key in the document. + # + # @return [ BSON::Document ] The document with transformed keys, or an Enumerator if no block given. + def transform_keys! + return enum_for(:transform_keys!) unless block_given? + + super { |key| convert_key(yield(key)) } end + # Transforms all values in the document using the given block. + # + # @yield [ value ] Each value in the document. + # + # @return [ BSON::Document ] A new document with transformed values, or an Enumerator if no block given. + def transform_values(&block) + return enum_for(:transform_values) unless block_given? + + dup.transform_values!(&block) + end + + # Transforms all values in the document in place using the given block. + # + # @yield [ value ] Each value in the document. + # + # @return [ BSON::Document ] The document with transformed values, or an Enumerator if no block given. + def transform_values! + return enum_for(:transform_values!) unless block_given? + + super { |value| convert_value(yield(value)) } + end + + # Returns a new hash with all keys converted to strings. + # + # @return [ BSON::Document ] A new document with string keys. + def stringify_keys + dup.stringify_keys! + end + + # Returns a new hash with all keys converted to symbols. + # + # @return [ Hash ] A new hash with symbol keys. + def symbolize_keys + to_h.symbolize_keys! + end + + # Raises an error because BSON::Document enforces string keys internally, + # and hence cannot be destructively modified to use symbol keys. + # + # @raise [ ArgumentError ] Indicates the method is not supported. def symbolize_keys! - raise ArgumentError, 'symbolize_keys! is not supported on BSON::Document instances. Please convert the document to hash first (using #to_h), then call #symbolize_keys! on the Hash instance' + raise ArgumentError, 'symbolize_keys! is not supported on BSON::Document instances. Instead call #symbolize_keys which returns a new Hash object.' + end + + # Returns a new hash with all keys (top-level and nested) as symbols. + # + # @return [ Hash ] A new hash with all keys as symbols. + def deep_symbolize_keys + to_h.deep_symbolize_keys! + end + + # Raises an error because BSON::Document enforces string keys internally, + # and hence cannot be destructively modified to use symbol keys. + # + # @raise [ ArgumentError ] Indicates the method is not supported. + def deep_symbolize_keys! + raise ArgumentError, 'deep_symbolize_keys! is not supported on BSON::Document instances. Instead call #deep_symbolize_keys which returns a new Hash object.' end # Override the Hash implementation of to_bson_normalized_value. @@ -326,8 +488,8 @@ def symbolize_keys! # BSON::Document is already of the correct type and already provides # indifferent access to keys, hence no further conversions are necessary. # - # Attempting to perform Hash's conversion on Document instances converts - # DBRefs to Documents which is wrong. + # Attempting to perform Hash's conversion on BSON::Document instances converts + # DBRef to BSON::Document which is wrong. # # @return [ BSON::Document ] The normalized hash. def to_bson_normalized_value diff --git a/spec/bson/document_as_spec.rb b/spec/bson/document_as_spec.rb index 984c116c9..dda7e6654 100644 --- a/spec/bson/document_as_spec.rb +++ b/spec/bson/document_as_spec.rb @@ -1,4 +1,4 @@ -# rubocop:todo all +# frozen_string_literal: true # Copyright (C) 2021 MongoDB Inc. # # Licensed under the Apache License, Version 2.0 (the "License"); @@ -13,34 +13,1000 @@ # See the License for the specific language governing permissions and # limitations under the License. -require "spec_helper" +require 'spec_helper' -# BSON::Document ActiveSupport extensions +# BSON::Document tests for ActiveSupport Hash extension method behaviors describe BSON::Document do require_active_support + let(:document) do + described_class.new('key1' => 'value1', 'key2' => 'value2', 'key3' => 'value3') + end + describe '#symbolize_keys' do - context 'string keys' do - let(:doc) do - described_class.new('foo' => 'bar') + let(:result) do + document.symbolize_keys + end + + it 'returns a Hash, not a BSON::Document' do + expect(result).to be_a(Hash) + expect(result).not_to be_a(described_class) + end + + it 'converts string keys to symbols' do + expect(result).to eq({ key1: 'value1', key2: 'value2', key3: 'value3' }) + end + + it 'does not modify the original document' do + result + expect(document).to eq(described_class.new('key1' => 'value1', 'key2' => 'value2', 'key3' => 'value3')) + end + + context 'with nested documents' do + let(:document) do + described_class.new('key1' => described_class.new('inner' => 'value')) + end + + let(:result) do + document.symbolize_keys end - it 'works correctly' do - doc.symbolize_keys.should == {foo: 'bar'} + it 'does not convert keys in nested documents' do + expect(result[:key1]).to eq({ 'inner' => 'value' }) + end + + it 'converts nested described_classs to plain Hashes' do + expect(result[:key1]).to be_a(Hash) + expect(result[:key1]).not_to be_a(described_class) end end end describe '#symbolize_keys!' do - context 'string keys' do - let(:doc) do - described_class.new('foo' => 'bar') + it 'raises ArgumentError' do + expect { document.symbolize_keys! }.to raise_error(ArgumentError, /symbolize_keys! is not supported/) + end + end + + describe '#deep_symbolize_keys' do + let(:document) do + described_class.new('key1' => 'value1', 'key2' => described_class.new('inner' => 'value')) + end + + let(:result) do + document.deep_symbolize_keys + end + + it 'returns a Hash, not a BSON::Document' do + expect(result).to be_a(Hash) + expect(result).not_to be_a(described_class) + end + + it 'converts string keys to symbols at all levels' do + expect(result).to eq({ key1: 'value1', key2: { inner: 'value' } }) + end + + it 'does not modify the original document' do + result + expect(document).to eq(described_class.new('key1' => 'value1', 'key2' => described_class.new('inner' => 'value'))) + end + end + + describe '#deep_symbolize_keys!' do + let(:document) do + described_class.new('key1' => 'value1', 'key2' => described_class.new('inner' => 'value')) + end + + it 'raises ArgumentError' do + expect { document.deep_symbolize_keys! }.to raise_error(ArgumentError, /deep_symbolize_keys! is not supported/) + end + end + + describe '#stringify_keys' do + let(:document) do + described_class.new(1 => 'value1', 'key2' => { 3 => :value3 }) + end + + let(:result) do + document.stringify_keys + end + + it 'returns a new BSON::Document' do + expect(result).to be_a(described_class) + expect(result).to_not be(document) + end + + it 'modifies only the top-level document keys' do + expect(result).to eq('1' => 'value1', 'key2' => { 3 => :value3 }) + end + end + + describe '#stringify_keys!' do + let(:document) do + described_class.new(1 => 'value1', 'key2' => { 3 => :value3 }) + end + + let(:result) do + document.stringify_keys! + end + + it 'returns self' do + expect(result).to be(document) + end + + it 'modifies only the top-level document keys' do + result + expect(document).to eq('1' => 'value1', 'key2' => { 3 => :value3 }) + end + end + + describe '#deep_stringify_keys' do + let(:document) do + described_class.new(1 => 'value1', 'key2' => { 3 => :value3 }) + end + + let(:result) do + document.deep_stringify_keys + end + + it 'returns a Hash' do + expect(result).to be_a(Hash) + end + + it 'converts all keys to strings at all levels' do + expect(result).to eq({ '1' => 'value1', 'key2' => { '3' => :value3 } }) + end + + it 'converts nested documents to Hash' do + expect(result['key2']).to be_a(Hash) + end + end + + describe '#deep_stringify_keys!' do + let(:document) do + described_class.new(1 => 'value1', 'key2' => { 3 => :value3 }) + end + + let(:result) do + document.deep_stringify_keys! + end + + it 'returns self' do + expect(result).to be(document) + end + + it 'modifies only the all levels of document keys' do + result + expect(document).to eq('1' => 'value1', 'key2' => { '3' => :value3 }) + end + end + + describe '#slice!' do + let(:result) do + document.slice!('key1', 'key3') + end + + it 'returns a new BSON::Document with removed keys' do + expect(result).to be_a(described_class) + expect(result).to eq(described_class.new('key2' => 'value2')) + end + + it 'modifies the original document' do + result + expect(document).to eq(described_class.new('key1' => 'value1', 'key3' => 'value3')) + end + + context 'when some keys do not exist' do + let(:document) do + described_class.new('key1' => 'value1', 'key2' => 'value2') + end + + let(:result) do + document.slice!('key1', 'nonexistent') + end + + it 'returns a document with the keys that were removed' do + expect(result).to eq(described_class.new('key2' => 'value2')) + end + + it 'modifies the original document' do + result + expect(document).to eq(described_class.new('key1' => 'value1')) + end + end + + context 'with symbol keys' do + let(:document) do + described_class.new(key1: 'value1', key2: 'value2') + end + + let(:result) do + document.slice!('key1') + end + + it 'returns a document with the keys that were removed' do + expect(result).to eq(described_class.new('key2' => 'value2')) + end + + it 'modifies the original document' do + result + expect(document).to eq(described_class.new('key1' => 'value1')) + end + end + end + + describe '#deep_merge' do + let(:document) do + described_class.new('key1' => 'value1', 'key2' => 'value2') + end + + context 'when merging with a simple hash' do + let(:other) do + { 'key2' => 'new_value', 'key3' => 'value3' } + end + + let(:result) do + document.deep_merge(other) + end + + it 'returns a new BSON::Document' do + expect(result).to be_a(described_class) + expect(result).not_to be(document) + end + + it 'includes all keys from both documents' do + expect(result.keys).to include('key1', 'key2', 'key3') + end + + it 'overwrites values for duplicate keys' do + expect(result['key2']).to eq('new_value') + end + + it 'does not modify the original document' do + expect(document['key2']).to eq('value2') + expect(document.keys).not_to include('key3') + end + end + + context 'when merging with a nested hash' do + let(:document) do + described_class.new( + 'key1' => 'value1', + 'nested' => described_class.new( + 'inner1' => 'value1', + 'inner2' => 'value2' + ) + ) + end + + let(:other) do + { + 'key2' => 'value2', + 'nested' => { + 'inner2' => 'new_value', + 'inner3' => 'value3' + } + } + end + + let(:result) do + document.deep_merge(other) + end + + it 'returns a new BSON::Document' do + expect(result).to be_a(described_class) + expect(result).not_to be(document) + end + + it 'includes top-level keys from both documents' do + expect(result.keys).to include('key1', 'key2', 'nested') + end + + it 'deeply merges nested documents' do + expect(result['nested'].keys).to include('inner1', 'inner2', 'inner3') + expect(result['nested']['inner1']).to eq('value1') + expect(result['nested']['inner2']).to eq('new_value') + expect(result['nested']['inner3']).to eq('value3') + end + + it 'returns nested documents as described_classs' do + expect(result['nested']).to be_a(described_class) + end + + it 'does not modify the original document' do + expect(document['nested']['inner2']).to eq('value2') + expect(document['nested'].keys).not_to include('inner3') + end + end + + context 'when merging with deeply nested hashes' do + let(:document) do + described_class.new( + 'level1' => described_class.new( + 'level2' => described_class.new( + 'level3' => described_class.new( + 'a' => 1, + 'b' => 2 + ) + ) + ) + ) + end + + let(:other) do + { + 'level1' => { + 'level2' => { + 'level3' => { + 'b' => 3, + 'c' => 4 + }, + 'new_key' => 'value' + } + } + } + end + + let(:result) do + document.deep_merge(other) + end + + it 'merges documents at all levels' do + expect(result['level1']['level2']['level3']['a']).to eq(1) + expect(result['level1']['level2']['level3']['b']).to eq(3) + expect(result['level1']['level2']['level3']['c']).to eq(4) + expect(result['level1']['level2']['new_key']).to eq('value') + end + + it 'returns BSON::Document at all nested levels' do + expect(result['level1']).to be_a(described_class) + expect(result['level1']['level2']).to be_a(described_class) + expect(result['level1']['level2']['level3']).to be_a(described_class) + end + end + + context 'when merging with arrays' do + let(:document) do + described_class.new( + 'key1' => 'value1', + 'array' => [1, 2, 3], + 'nested' => described_class.new( + 'array' => [4, 5, 6] + ) + ) + end + + let(:other) do + { + 'key2' => 'value2', + 'array' => [7, 8, 9], + 'nested' => { + 'array' => [10, 11, 12] + } + } + end + + let(:result) do + document.deep_merge(other) + end + + it 'replaces arrays instead of merging them' do + expect(result['array']).to eq([7, 8, 9]) + expect(result['nested']['array']).to eq([10, 11, 12]) + end + end + + context 'when merging with non-hash values' do + let(:document) do + described_class.new( + 'key1' => 'value1', + 'key2' => described_class.new('inner' => 'value') + ) + end + + let(:other) do + { + 'key1' => 'new_value', + 'key2' => 'not_a_hash' + } + end + + let(:result) do + document.deep_merge(other) + end + + it 'overwrites non-hash values' do + expect(result['key1']).to eq('new_value') + expect(result['key2']).to eq('not_a_hash') + end + end + + context 'when a block is provided' do + let(:document) do + described_class.new( + 'key1' => 'value1', + 'nested' => described_class.new( + 'inner' => 5 + ) + ) + end + + let(:other) do + { + 'key1' => 'new_value', + 'nested' => { + 'inner' => 10 + } + } + end + + let(:result) do + document.deep_merge(other) do |key, old_value, new_value| + if key == 'inner' && old_value.is_a?(Integer) && new_value.is_a?(Integer) + old_value + new_value + else + new_value + end + end + end + + it 'applies the block to resolve conflicts' do + expect(result['key1']).to eq('new_value') + expect(result['nested']['inner']).to eq(15) # 5 + 10 + end + end + end + + describe '#deep_merge!' do + let(:document) do + described_class.new('key1' => 'value1', 'key2' => 'value2') + end + + context 'when merging with a simple hash' do + let(:other) do + { 'key2' => 'new_value', 'key3' => 'value3' } + end + + let(:result) do + document.deep_merge!(other) + end + + it 'returns self' do + expect(result).to be(document) + end + + it 'includes all keys from both documents' do + result + expect(document.keys).to include('key1', 'key2', 'key3') + end + + it 'overwrites values for duplicate keys' do + result + expect(document['key2']).to eq('new_value') + end + end + + context 'when merging with a nested hash' do + let(:document) do + described_class.new( + 'key1' => 'value1', + 'nested' => described_class.new( + 'inner1' => 'value1', + 'inner2' => 'value2' + ) + ) + end + + let(:other) do + { + 'key2' => 'value2', + 'nested' => { + 'inner2' => 'new_value', + 'inner3' => 'value3' + } + } + end + + let(:result) do + document.deep_merge!(other) + end + + it 'returns self' do + expect(result).to be(document) + end + + it 'includes top-level keys from both documents' do + result + expect(document.keys).to include('key1', 'key2', 'nested') + end + + it 'deeply merges nested documents' do + result + expect(document['nested'].keys).to include('inner1', 'inner2', 'inner3') + expect(document['nested']['inner1']).to eq('value1') + expect(document['nested']['inner2']).to eq('new_value') + expect(document['nested']['inner3']).to eq('value3') + end + + it 'returns nested documents as BSON::Document' do + result + expect(document['nested']).to be_a(described_class) + end + end + + context 'when merging with deeply nested hashes' do + let(:document) do + described_class.new( + 'level1' => described_class.new( + 'level2' => described_class.new( + 'level3' => described_class.new( + 'a' => 1, + 'b' => 2 + ) + ) + ) + ) + end + + let(:other) do + { + 'level1' => { + 'level2' => { + 'level3' => { + 'b' => 3, + 'c' => 4 + }, + 'new_key' => 'value' + } + } + } + end + + let(:result) do + document.deep_merge!(other) + end + + it 'merges documents at all levels' do + result + expect(document['level1']['level2']['level3']['a']).to eq(1) + expect(document['level1']['level2']['level3']['b']).to eq(3) + expect(document['level1']['level2']['level3']['c']).to eq(4) + expect(document['level1']['level2']['new_key']).to eq('value') + end + + it 'returns BSON::Document at all nested levels' do + result + expect(document['level1']).to be_a(described_class) + expect(document['level1']['level2']).to be_a(described_class) + expect(document['level1']['level2']['level3']).to be_a(described_class) + end + end + + context 'when merging with arrays' do + let(:document) do + described_class.new( + 'key1' => 'value1', + 'array' => [1, 2, 3], + 'nested' => described_class.new( + 'array' => [4, 5, 6] + ) + ) + end + + let(:other) do + { + 'key2' => 'value2', + 'array' => [7, 8, 9], + 'nested' => { + 'array' => [10, 11, 12] + } + } + end + + let(:result) do + document.deep_merge!(other) + end + + it 'replaces arrays instead of merging them' do + result + expect(document['array']).to eq([7, 8, 9]) + expect(document['nested']['array']).to eq([10, 11, 12]) + end + end + + context 'when merging with non-hash values' do + let(:document) do + described_class.new( + 'key1' => 'value1', + 'key2' => described_class.new('inner' => 'value') + ) + end + + let(:other) do + { + 'key1' => 'new_value', + 'key2' => 'not_a_hash' + } + end + + let(:result) do + document.deep_merge!(other) + end + + it 'overwrites non-hash values' do + result + expect(document['key1']).to eq('new_value') + expect(document['key2']).to eq('not_a_hash') + end + end + + context 'when a block is provided' do + let(:document) do + described_class.new( + 'key1' => 'value1', + 'nested' => described_class.new( + 'inner' => 5 + ) + ) + end + + let(:other) do + { + 'key1' => 'new_value', + 'nested' => { + 'inner' => 10 + } + } + end + + let(:result) do + document.deep_merge!(other) do |key, old_value, new_value| + if key == 'inner' && old_value.is_a?(Integer) && new_value.is_a?(Integer) + old_value + new_value + else + new_value + end + end + end + + it 'applies the block to resolve conflicts' do + result + expect(document['key1']).to eq('new_value') + expect(document['nested']['inner']).to eq(15) # 5 + 10 + end + end + + context 'when merging with nil values' do + let(:document) do + described_class.new( + 'key1' => 'value1', + 'key2' => 'value2' + ) + end + + let(:other) do + { + 'key1' => nil, + 'key3' => nil + } + end + + let(:result) do + document.deep_merge!(other) + end + + it 'overwrites existing values with nil' do + result + expect(document['key1']).to be_nil + end + + it 'adds new keys with nil values' do + result + expect(document.key?('key3')).to be true + expect(document['key3']).to be_nil + end + end + + context 'when merging with deeply nested identical structures' do + let(:document) do + described_class.new( + 'config' => described_class.new( + 'options' => described_class.new( + 'timeout' => 30, + 'retry' => true + ) + ) + ) + end + + let(:other) do + { + 'config' => { + 'options' => { + 'timeout' => 60 + } + } + } + end + + let(:result) do + document.deep_merge!(other) + end + + it 'preserves unmodified nested values' do + result + expect(document['config']['options']['retry']).to be true + end + + it 'updates modified nested values' do + result + expect(document['config']['options']['timeout']).to eq(60) + end + + it 'maintains the BSON::Document class throughout the structure' do + result + expect(document['config']).to be_a(described_class) + expect(document['config']['options']).to be_a(described_class) + end + end + + context 'when the type of a nested structure changes' do + let(:document) do + described_class.new( + 'key' => described_class.new( + 'was_hash' => true + ) + ) + end + + let(:other) do + { + 'key' => 'now a string' + } + end + + let(:result) do + document.deep_merge!(other) + end + + it 'replaces the nested structure with the new type' do + result + expect(document['key']).to eq('now a string') + end + end + end + + describe '#extract!' do + context 'with string keys' do + let(:extracted) do + document.extract!('key1', 'key3') + end + + it 'returns a document with extracted pairs' do + expect(extracted).to be_a(described_class) + expect(extracted).to eq(described_class.new('key1' => 'value1', 'key3' => 'value3')) + end + + it 'removes extracted pairs from original document' do + extracted + expect(document).to eq(described_class.new('key2' => 'value2')) + end + end + + context 'with symbol keys' do + let(:extracted) do + document.extract!(:key1, :key3) + end + + it 'returns a document with extracted pairs' do + expect(extracted).to be_a(described_class) + expect(extracted).to eq(described_class.new('key1' => 'value1', 'key3' => 'value3')) + end + end + + context 'with missing keys' do + let(:extracted) do + document.extract!('key1', 'missing') + end + + it 'ignores missing keys' do + expect(extracted).to eq(described_class.new('key1' => 'value1')) + end + end + + context 'with nested documents' do + let(:document) do + described_class.new( + 'key1' => 'value1', + 'nested' => described_class.new('inner1' => 'nested_value1', 'inner2' => 'nested_value2') + ) + end + + let(:extracted) do + document.extract!('key1', 'nested') + end + + it 'returns nested documents as BSON::Documents' do + expect(extracted['nested']).to be_a(described_class) + end + end + end + + describe '#without' do + context 'with string keys' do + let(:result) do + document.without('key1', 'key3') + end + + it 'returns a document without the specified keys' do + expect(result).to be_a(described_class) + expect(result).to eq(described_class.new('key2' => 'value2')) + end + + it 'does not modify the original document' do + result + expect(document).to eq(described_class.new( + 'key1' => 'value1', 'key2' => 'value2', 'key3' => 'value3' + )) + end + end + + context 'with symbol keys' do + let(:result) do + document.without(:key1, :key3) + end + + it 'returns a document without the specified keys' do + expect(result).to eq(described_class.new('key2' => 'value2')) + end + end + + context 'with missing keys' do + let(:result) do + document.without('key1', 'missing') + end + + it 'ignores missing keys' do + expect(result).to eq(described_class.new('key2' => 'value2', 'key3' => 'value3')) + end + end + end + + describe '#with_indifferent_access' do + let(:document) do + described_class.new('key1' => 'value1', :key2 => 'value2') + end + + let(:result) do + document.with_indifferent_access + end + + it 'returns a HashWithIndifferentAccess' do + expect(result).to be_a(ActiveSupport::HashWithIndifferentAccess) + end + + it 'allows access with both strings and symbols' do + expect(result['key1']).to eq('value1') + expect(result[:key1]).to eq('value1') + expect(result['key2']).to eq('value2') + expect(result[:key2]).to eq('value2') + end + + context 'with nested documents' do + let(:document) do + described_class.new( + 'key1' => 'value1', + 'nested' => described_class.new('inner' => 'value') + ) + end + + let(:result) do + document.with_indifferent_access + end + + it 'converts nested documents to HashWithIndifferentAccess' do + expect(result[:nested]).to be_a(ActiveSupport::HashWithIndifferentAccess) + expect(result[:nested][:inner]).to eq('value') + expect(result['nested']['inner']).to eq('value') + end + end + end + + describe '#compact_blank' do + let(:document) do + described_class.new( + 'key1' => 'value1', + 'key2' => '', + 'key3' => nil, + 'key4' => [], + 'key5' => {} + ) + end + + let(:result) do + document.compact_blank + end + + it 'returns a BSON::Document' do + expect(result).to be_a(described_class) + end + + it 'removes blank values' do + expect(result.keys).to eq(['key1']) + expect(result['key1']).to eq('value1') + end + + it 'does not modify the original document' do + result + expect(document.keys).to eq(%w[key1 key2 key3 key4 key5]) + end + + context 'with nested documents' do + let(:document) do + described_class.new( + 'key1' => 'value1', + 'nested' => described_class.new('inner1' => '', 'inner2' => 'value') + ) + end + + let(:result) do + document.compact_blank + end + + it 'does not compact blank values in nested documents' do + expect(result['nested']['inner1']).to eq('') + expect(result['nested']['inner2']).to eq('value') + end + + it 'preserves BSON::Document type for nested documents' do + expect(result['nested']).to be_a(described_class) + end + end + end + + describe '#compact_blank!' do + let(:document) do + described_class.new( + 'key1' => 'value1', + 'key2' => '', + 'key3' => nil, + 'key4' => [], + 'key5' => {} + ) + end + + context 'when changes are made' do + let(:result) do + document.compact_blank! + end + + it 'returns self' do + expect(result).to be(document) + end + + it 'removes blank values' do + result + expect(document.keys).to eq(['key1']) + expect(document['key1']).to eq('value1') + end + end + + context 'when no changes are made' do + let(:document) do + described_class.new('key1' => 'value1', 'key2' => 'value2') + end + + let(:result) do + document.compact_blank! + end + + it 'returns self' do + expect(result).to be(document) end - it 'raises ArgumentError' do - lambda do - doc.symbolize_keys! - end.should raise_error(ArgumentError, /symbolize_keys! is not supported on BSON::Document instances/) + it 'does not modify the original document' do + result + expect(document).to eq(described_class.new('key1' => 'value1', 'key2' => 'value2')) end end end diff --git a/spec/bson/document_native_spec.rb b/spec/bson/document_native_spec.rb new file mode 100644 index 000000000..cbc038af2 --- /dev/null +++ b/spec/bson/document_native_spec.rb @@ -0,0 +1,1949 @@ +# frozen_string_literal: true +# Copyright (C) 2009-2020 MongoDB Inc. +# +# Licensed under the Apache License, Version 2.0 (the "License"); +# you may not use this file except in compliance with the License. +# You may obtain a copy of the License at +# +# http://www.apache.org/licenses/LICENSE-2.0 +# +# Unless required by applicable law or agreed to in writing, software +# distributed under the License is distributed on an "AS IS" BASIS, +# WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +# See the License for the specific language governing permissions and +# limitations under the License. + +require 'spec_helper' + +# BSON::Document tests for native Hash method behaviors +describe BSON::Document do + let(:document) do + described_class.new('key1' => 'value1', 'key2' => 'value2', 'key3' => 'value3') + end + + describe '.try_convert' do + let(:object) do + { 'key1' => 'value1' } + end + + let(:document) do + described_class.try_convert(object) + end + + it 'converts the object to a document' do + expect(document).to be_a(described_class) + expect(document).to eq(described_class.new('key1' => 'value1')) + end + + context 'when the object is contains a nested hash' do + let(:object) do + { 'key1' => 'value1', 'nested' => { 'key2' => 'value2' } } + end + + it 'converts the nested hash to a document' do + nested = document['nested'] + expect(nested).to be_a(described_class) + expect(nested).to eq(described_class.new('key2' => 'value2')) + end + end + + context 'when the object is a BSON::Document' do + let(:object) do + described_class.new('key1' => 'value1') + end + + it 'returns the document itself self' do + expect(document).to eq(object) + end + end + + context 'when the object is not convertible to a hash' do + let(:object) do + 'not a hash' + end + + it 'returns nil' do + expect(document).to be_nil + end + end + end + + describe '.[]' do + context 'with key-value pairs' do + let(:document) do + described_class['key1', 'value1', 'key2', 'value2'] + end + + it 'creates a document with the provided keys and values' do + expect(document).to be_a(described_class) + expect(document).to eq(described_class.new('key1' => 'value1', 'key2' => 'value2')) + end + end + + context 'with a hash-like object' do + let(:hash) do + { 'key' => 'value' } + end + + let(:document) do + described_class[hash] + end + + it 'creates a document from the hash-like object' do + expect(document).to be_a(described_class) + expect(document).to eq(described_class.new('key' => 'value')) + end + end + end + + describe '#to_h' do + context 'with a single-level document' do + let(:document) do + described_class.new('key1' => 'value1', 'key2' => 'value2') + end + + let(:hash) do + document.to_h + end + + it 'returns a Hash' do + expect(hash).to be_a(Hash) + expect(hash).to_not be_a(described_class) + end + + it 'returns a hash with the same keys and values' do + expect(hash).to eq({ 'key1' => 'value1', 'key2' => 'value2' }) + end + end + + context 'with a nested document' do + let(:document) do + described_class.new('key1' => 'value1', 'key2' => described_class.new('key3' => 'value3')) + end + + let(:hash) do + document.to_h + end + + it 'converts nested documents to hashes' do + nested = hash['key2'] + expect(nested).to be_a(Hash) + expect(nested).to_not be_a(described_class) + end + + it 'preserves the nested structure' do + expect(hash).to eq({ 'key1' => 'value1', 'key2' => { 'key3' => 'value3' } }) + end + end + + context 'when a block is provided' do + let(:document) do + described_class.new('key1' => 'value1', 'key2' => 'value2') + end + + let(:hash) do + document.to_h { |k, v| [k.to_sym, v.upcase] } + end + + it 'returns a Hash' do + expect(hash).to be_a(Hash) + expect(hash).to_not be_a(described_class) + end + + it 'applies the block to each key-value pair' do + expect(hash).to eq({ key1: 'VALUE1', key2: 'VALUE2' }) + end + end + end + + describe '#to_hash' do + let(:document) do + described_class.new('key1' => 'value1', 'key2' => { 'key3' => 'value3' }) + end + + it 'is an alias for #to_h' do + expect(document.method(:to_hash)).to eq(document.method(:to_h)) + end + + let(:hash) do + document.to_hash + end + + it 'returns a Hash' do + expect(hash).to be_a(Hash) + expect(hash).to_not be_a(described_class) + end + + it 'converts nested documents' do + nested = hash['key2'] + expect(nested).to be_a(Hash) + expect(nested).to_not be_a(described_class) + end + + it 'contains the correct keys and values' do + expect(hash).to eq('key1' => 'value1', 'key2' => { 'key3' => 'value3' }) + end + end + + describe '#[]=' do + context 'with string keys' do + let(:result) do + document['key4'] = 'value4' + end + + it 'adds the key-value pair to the document' do + result + expect(document['key4']).to eq('value4') + end + + it 'returns the value' do + expect(result).to eq('value4') + end + end + + context 'with symbol keys' do + let(:result) do + document[:key4] = 'value4' + end + + it 'adds the key-value pair with a string key' do + result + expect(document['key4']).to eq('value4') + end + + it 'allows lookup with both string and symbol' do + result + expect(document[:key4]).to eq('value4') + expect(document['key4']).to eq('value4') + end + end + + context 'with hash values' do + let(:result) do + document['nested'] = { 'inner' => 'value' } + end + + it 'converts hash values to BSON::Document' do + result + expect(document['nested']).to be_a(described_class) + expect(document['nested']['inner']).to eq('value') + end + end + + context 'with array values containing hashes' do + let(:result) do + document['array'] = [1, 2, { 'a' => 1 }] + end + + it 'converts hashes within arrays to BSON::Document' do + result + expect(document['array'][2]).to be_a(described_class) + expect(document['array'][2]['a']).to eq(1) + end + end + + context 'when overwriting an existing key' do + let(:result) do + document['key1'] = 'new_value' + end + + it 'replaces the value for the key' do + result + expect(document['key1']).to eq('new_value') + end + + it 'does not change the order of keys' do + result + expect(document.keys).to eq(%w[key1 key2 key3]) + end + end + + context 'with nested documents' do + let(:nested_doc) do + described_class.new('inner' => 'value') + end + + let(:result) do + document['nested'] = nested_doc + end + + it 'preserves BSON::Document values' do + result + expect(document['nested']).to be(nested_doc) + expect(document['nested']).to be_a(described_class) + end + end + end + + describe '#store' do + it 'is an alias for []=' do + expect(document.method(:store)).to eq(document.method(:[]=)) + end + end + + describe '#has_key?' do + context 'with existing string keys' do + it 'returns true' do + expect(document.has_key?('key1')).to be true + end + end + + context 'with existing symbol keys' do + it 'returns true' do + expect(document.has_key?(:key1)).to be true + end + end + + context 'with non-existent keys' do + it 'returns false' do + expect(document.has_key?('non_existent')).to be false + end + end + end + + describe '#include?' do + it 'is an alias for has_key?' do + expect(document.method(:include?)).to eq(document.method(:has_key?)) + end + end + + describe '#key?' do + it 'is an alias for has_key?' do + expect(document.method(:key?)).to eq(document.method(:has_key?)) + end + end + + describe '#member?' do + it 'is an alias for has_key?' do + expect(document.method(:member?)).to eq(document.method(:has_key?)) + end + end + + describe '#key' do + context 'with existing values' do + let(:result) do + document.key('value1') + end + + it 'returns the key for the value' do + expect(result).to eq('key1') + end + end + + context 'with multiple matching values' do + let(:document_with_duplicates) do + described_class.new('key1' => 'duplicate', 'key2' => 'duplicate') + end + + let(:result) do + document_with_duplicates.key('duplicate') + end + + it 'returns the first matching key' do + expect(result).to eq('key1') + end + end + + context 'with non-existent values' do + let(:result) do + document.key('non_existent') + end + + it 'returns nil for non-existent values' do + expect(result).to be_nil + end + end + + context 'with symbol values' do + let(:document_with_symbols) do + described_class.new('key1' => :symbol_value) + end + + let(:result) do + document_with_symbols.key(:symbol_value) + end + + it 'converts symbol values correctly' do + expect(result).to eq('key1') + end + end + + context 'with nested document values' do + let(:nested_doc) do + described_class.new('inner' => 'value') + end + + let(:document_with_nested) do + described_class.new('key1' => nested_doc) + end + + let(:result) do + document_with_nested.key(nested_doc) + end + + it 'can find BSON::Document values' do + expect(result).to eq('key1') + end + + context 'when searching with an equivalent hash' do + let(:result) do + document_with_nested.key({ 'inner' => 'value' }) + end + + it 'finds the key by equivalent hash' do + expect(result).to eq('key1') + end + end + end + end + + describe '#default' do + context 'without default value' do + let(:default) do + document.default + end + + it 'returns nil' do + expect(default).to be_nil + end + end + + context 'with default value' do + let(:document_with_default) do + doc = described_class.new('key1' => 'value1') + doc.default = 'default_value' + doc + end + + let(:default) do + document_with_default.default + end + + it 'returns the default value' do + expect(default).to eq('default_value') + end + end + + context 'with default proc' do + let(:document_with_default_proc) do + doc = described_class.new('key1' => 'value1') + doc.default_proc = ->(_hash, key) { "default_for_#{key}" } + doc + end + + let(:default) do + document_with_default_proc.default('missing') + end + + it 'returns the processed default value' do + expect(default).to eq('default_for_missing') + end + end + end + + describe '#default=' do + let(:document_with_default) do + doc = described_class.new('key1' => 'value1') + doc.default = 'default_value' + doc + end + + it 'sets the default value' do + expect(document_with_default.default).to eq('default_value') + end + + it 'returns the default value for missing keys' do + expect(document_with_default['missing']).to eq('default_value') + end + end + + describe '#has_value?' do + context 'with existing values' do + it 'returns true' do + expect(document.has_value?('value1')).to be true + end + end + + context 'with symbol values' do + let(:document_with_symbols) do + described_class.new('key1' => :symbol_value) + end + + it 'returns true when searching with a symbol' do + expect(document_with_symbols.has_value?(:symbol_value)).to be true + end + end + + context 'with non-existent values' do + it 'returns false' do + expect(document.has_value?('non_existent')).to be false + end + end + end + + describe '#value?' do + it 'is an alias for has_value?' do + expect(document.method(:value?)).to eq(document.method(:has_value?)) + end + end + + describe '#values_at' do + context 'with string keys' do + let(:values) do + document.values_at('key1', 'key3') + end + + it 'returns the values for the keys' do + expect(values).to eq(%w[value1 value3]) + end + end + + context 'with symbol keys' do + let(:values) do + document.values_at(:key1, :key3) + end + + it 'returns the values for the keys' do + expect(values).to eq(%w[value1 value3]) + end + end + + context 'with missing keys' do + let(:values) do + document.values_at('key1', 'missing') + end + + it 'returns nil for missing keys' do + expect(values).to eq(['value1', nil]) + end + end + + context 'with nested documents' do + let(:document) do + described_class.new( + 'key1' => 'value1', + 'nested' => described_class.new('inner1' => 'nested_value1', 'inner2' => 'nested_value2') + ) + end + + let(:values) do + document.values_at('key1', 'nested') + end + + it 'returns the values for the keys' do + expect(values[0]).to eq('value1') + expect(values[1]).to be_a(described_class) + expect(values[1]['inner1']).to eq('nested_value1') + end + end + end + + describe '#assoc' do + context 'with string keys' do + let(:pair) do + document.assoc('key1') + end + + it 'returns the key-value pair' do + expect(pair).to eq(%w[key1 value1]) + end + end + + context 'with symbol keys' do + let(:pair) do + document.assoc(:key1) + end + + it 'returns the key-value pair' do + expect(pair).to eq(%w[key1 value1]) + end + end + + context 'with missing keys' do + let(:pair) do + document.assoc('missing') + end + + it 'returns nil for missing keys' do + expect(pair).to be_nil + end + end + end + + describe '#rassoc' do + context 'with existing values' do + let(:result) do + document.rassoc('value1') + end + + it 'returns the key-value pair' do + expect(result).to eq(['key1', 'value1']) + end + end + + context 'with multiple matching values' do + let(:document_with_duplicates) do + described_class.new('key1' => 'duplicate', 'key2' => 'duplicate') + end + + let(:result) do + document_with_duplicates.rassoc('duplicate') + end + + it 'returns the first matching pair' do + expect(result).to eq(['key1', 'duplicate']) + end + end + + context 'with non-existent values' do + let(:result) do + document.rassoc('non_existent') + end + + it 'returns nil for non-existent values' do + expect(result).to be_nil + end + end + + context 'with symbol values' do + let(:document_with_symbols) do + described_class.new('key1' => :symbol_value) + end + + context 'when searching with a symbol' do + let(:result) do + document_with_symbols.rassoc(:symbol_value) + end + + it 'finds the key-value pair' do + expect(result).to eq(['key1', :symbol_value]) + end + end + + context 'when searching with a string' do + let(:result) do + document_with_symbols.rassoc('symbol_value') + end + + it 'does not find the key-value pair' do + expect(result).to be_nil + end + end + end + + context 'with nested document values' do + let(:nested_doc) do + described_class.new('inner' => 'value') + end + + let(:document_with_nested) do + described_class.new('key1' => nested_doc) + end + + let(:result) do + document_with_nested.rassoc(nested_doc) + end + + it 'can find BSON::Document values' do + expect(result).to eq(['key1', nested_doc]) + end + + context 'when searching with an equivalent hash' do + let(:result) do + document_with_nested.rassoc({ 'inner' => 'value' }) + end + + it 'finds the pair by equivalent hash' do + expect(result).to eq(['key1', { 'inner' => 'value' }]) + end + end + end + end + + describe '#fetch_values' do + context 'with string keys' do + let(:values) do + document.fetch_values('key1', 'key3') + end + + it 'returns the values for the keys' do + expect(values).to eq(%w[value1 value3]) + end + end + + context 'with symbol keys' do + let(:values) do + document.fetch_values(:key1, :key3) + end + + it 'returns the values for the keys' do + expect(values).to eq(%w[value1 value3]) + end + end + + context 'with missing keys and no block' do + it 'raises KeyError for missing keys' do + expect { + document.fetch_values('key1', 'missing') + }.to raise_error(KeyError) + end + end + + context 'with missing keys and a block' do + let(:values) do + document.fetch_values('key1', 'missing') { |key| "default_for_#{key}" } + end + + it 'uses the block for missing keys' do + expect(values).to eq(%w[value1 default_for_missing]) + end + end + end + + describe '#invert' do + let(:document) do + described_class.new('key1' => 'value1', 'key2' => 'value2') + end + + let(:inverted) do + document.invert + end + + it 'returns a new BSON::Document' do + expect(inverted).to be_a(described_class) + expect(inverted).not_to be(document) + end + + it 'inverts keys and values' do + expect(inverted).to eq(described_class.new('value1' => 'key1', 'value2' => 'key2')) + end + + context 'with nested documents' do + let(:document) do + described_class.new('key1' => 'value1', 'key2' => described_class.new('nested' => 'value2')) + end + + let(:inverted) do + document.invert + end + + let(:nested_key) do + inverted.keys.detect { |k| k.include?('nested') } + end + + it 'does convert nested documents' do + expect(nested_key).to be_a(described_class) + expect(nested_key).to eq(document['key2']) + end + + it 'does not attempt to invert nested documents recursively' do + expect(inverted[nested_key]).to eq('key2') + end + end + end + + describe '#rehash' do + let(:document) do + described_class.new('key1' => 'value1', 'key2' => 'value2') + end + + it 'returns self' do + expect(document.rehash).to be(document) + end + + context 'with mutable keys' do + let(:mutable_key) do + { id: 1 } + end + + let(:document) do + described_class.new(mutable_key => 'value') + end + + before do + mutable_key[:id] = 2 + end + + it 'rebuilds hash index after key changes' do + expect { document.rehash }.not_to raise_error + expect(document.keys.first).to eq({ id: 2 }) + end + end + end + + describe '#delete' do + let(:document) do + described_class.new('key1' => 'value1', 'key2' => 'value2') + end + + context 'when the key exists' do + it 'returns the value' do + expect(document.delete('key1')).to eq('value1') + end + + it 'removes the key-value pair' do + document.delete('key1') + expect(document).to eq(described_class.new('key2' => 'value2')) + end + end + + context 'when the key is a symbol' do + let(:document) do + described_class.new(key1: 'value1', 'key2' => 'value2') + end + + it 'returns the value' do + expect(document.delete(:key1)).to eq('value1') + end + + it 'removes the key-value pair' do + document.delete(:key1) + expect(document).to eq(described_class.new('key2' => 'value2')) + end + end + + context 'when the key does not exist' do + it 'returns nil' do + expect(document.delete('nonexistent')).to be_nil + end + end + + context 'when a block is provided' do + let(:value) do + document.delete('key1') { |key| "default for #{key}" } + end + + it 'returns the result of the block' do + expect(value).to eq('value1') + end + end + + context 'when a block is provided and the key does not exist' do + let(:value) do + document.delete('nonexistent') { |key| "default for #{key}" } + end + + it 'returns the result of the block' do + expect(value).to eq('default for nonexistent') + end + end + end + + describe '#clear' do + let(:document) do + described_class.new('key1' => 'value1', 'key2' => 'value2') + end + + let(:result) do + document.clear + end + + it 'returns self' do + expect(result).to be(document) + end + + it 'removes all key-value pairs' do + result + expect(document).to be_empty + end + end + + describe '#shift' do + let(:document) do + described_class.new('key1' => 'value1', 'key2' => 'value2') + end + + let(:pair) do + document.shift + end + + it 'returns the first key-value pair as an array' do + expect(pair).to eq(%w[key1 value1]) + end + + it 'removes the first key-value pair from the document' do + document.shift + expect(document).to eq(described_class.new('key2' => 'value2')) + end + + context 'when the document is empty' do + let(:empty_document) do + described_class.new + end + + it 'returns nil' do + expect(empty_document.shift).to be_nil + end + end + end + + describe '#merge' do + let(:document) do + described_class.new('key1' => 'value1', 'key2' => 'value2') + end + + context 'when merging with another document' do + let(:other) do + described_class.new('key2' => 'new_value', 'key3' => 'value3') + end + + let(:result) do + document.merge(other) + end + + it 'returns a new BSON::Document' do + expect(result).to be_a(described_class) + expect(result).not_to be(document) + end + + it 'includes all keys from both documents' do + expect(result.keys).to include('key1', 'key2', 'key3') + end + + it 'uses values from the other document for duplicate keys' do + expect(result['key2']).to eq('new_value') + end + end + + context 'when merging with a hash' do + let(:other) do + { 'key2' => 'new_value', 'key3' => 'value3' } + end + + let(:result) do + document.merge(other) + end + + it 'returns a BSON::Document' do + expect(result).to be_a(described_class) + end + + it 'includes all keys from both documents' do + expect(result.keys).to include('key1', 'key2', 'key3') + end + end + + context 'when a block is provided' do + let(:other) do + { 'key1' => 'other_value', 'key3' => 'value3' } + end + + let(:result) do + document.merge(other) do |_key, old_val, new_val| + "#{old_val} and #{new_val}" + end + end + + it 'uses the result of the block for duplicate keys' do + expect(result['key1']).to eq('value1 and other_value') + end + + it 'uses the value of the other hash for non-duplicate keys' do + expect(result['key3']).to eq('value3') + end + end + + context 'with nested documents' do + let(:document) do + described_class.new('key1' => 'value1', 'nested' => described_class.new('a' => 1, 'b' => 2)) + end + + let(:other) do + { 'key2' => 'value2', 'nested' => { 'b' => 3, 'c' => 4 } } + end + + let(:result) do + document.merge(other) + end + + it 'replaces the nested document' do + expect(result['nested']).to eq(described_class.new('b' => 3, 'c' => 4)) + end + + it 'returns nested BSON::Document' do + expect(result['nested']).to be_a(described_class) + end + end + end + + describe '#merge!' do + let(:document) do + described_class.new('key1' => 'value1', 'key2' => 'value2') + end + + context 'when merging with another document' do + let(:other) do + described_class.new('key2' => 'new_value', 'key3' => 'value3') + end + + let(:result) do + document.merge!(other) + end + + it 'returns self' do + expect(result).to be(document) + end + + it 'modifies the original document' do + result + expect(document['key2']).to eq('new_value') + expect(document['key3']).to eq('value3') + end + end + + context 'when merging with a hash' do + let(:other) do + { 'key2' => 'new_value', 'key3' => 'value3' } + end + + let(:result) do + document.merge!(other) + end + + it 'returns self' do + expect(result).to be(document) + end + + it 'modifies the original document' do + result + expect(document['key2']).to eq('new_value') + expect(document['key3']).to eq('value3') + end + end + + context 'when a block is provided' do + let(:other) do + { 'key1' => 'other_value', 'key3' => 'value3' } + end + + let(:result) do + document.merge!(other) do |_key, old_val, new_val| + "#{old_val} and #{new_val}" + end + end + + it 'uses the result of the block for duplicate keys' do + result + expect(document['key1']).to eq('value1 and other_value') + end + + it 'uses the value of the other hash for non-duplicate keys' do + result + expect(document['key3']).to eq('value3') + end + end + + context 'with nested documents' do + let(:document) do + described_class.new('key1' => 'value1', 'nested' => described_class.new('a' => 1, 'b' => 2)) + end + + let(:other) do + { 'key2' => 'value2', 'nested' => { 'b' => 3, 'c' => 4 } } + end + + let(:result) do + document.merge!(other) + end + + it 'replaces the nested document' do + result + expect(document['nested']).to eq(described_class.new('b' => 3, 'c' => 4)) + end + + it 'converts the nested hash to a BSON::Document' do + result + expect(document['nested']).to be_a(described_class) + end + end + end + + describe '#update' do + let(:document) do + described_class.new('key1' => 'value1') + end + + let(:other) do + { 'key2' => 'value2' } + end + + it 'is an alias for merge!' do + expect(document.method(:update)).to eq(document.method(:merge!)) + end + + it 'updates the document in place' do + document.update(other) + expect(document).to eq(described_class.new('key1' => 'value1', 'key2' => 'value2')) + end + end + + describe '#reject' do + let(:result) do + document.reject { |k, v| k == 'key1' || v == 'value3' } + end + + it 'returns a new BSON::Document' do + expect(result).to be_a(described_class) + expect(result).not_to be(document) + end + + it 'excludes keys for which the block returns true' do + expect(result).to eq(described_class.new('key2' => 'value2')) + end + + it 'does not modify the original document' do + result + expect(document).to eq(described_class.new('key1' => 'value1', 'key2' => 'value2', 'key3' => 'value3')) + end + + context 'when no changes are made' do + let(:result) do + document.reject { |_k, _v| false } + end + + it 'returns a new BSON::Document' do + expect(result).to be_a(described_class) + expect(result).not_to be(document) + end + + it 'returns all original keys and values' do + expect(result).to eq(described_class.new('key1' => 'value1', 'key2' => 'value2', 'key3' => 'value3')) + end + + it 'does not modify the original document' do + result + expect(document).to eq(described_class.new('key1' => 'value1', 'key2' => 'value2', 'key3' => 'value3')) + end + end + + context 'when block not given' do + let(:enumerator) do + document.reject + end + + it 'returns an enumerator' do + expect(enumerator).to be_a(Enumerator) + end + + it 'enumerates over all key-value pairs' do + pairs = enumerator.to_a + expect(pairs.length).to eq(document.length) + expect(pairs.map(&:first)).to eq(document.keys) + expect(pairs.map(&:last)).to eq(document.values) + end + + it 'produces a BSON::Document when used with a block' do + result = enumerator.each { |key, value| key == 'key1' || value == 'value3' } + expect(result).to be_a(described_class) + expect(result).to eq(described_class.new('key2' => 'value2')) + end + end + end + + describe '#reject!' do + context 'when changes are made' do + let(:result) do + document.reject! { |k, v| k == 'key1' || v == 'value3' } + end + + it 'returns self' do + expect(result).to be(document) + end + + it 'modifies the original document' do + result + expect(document).to eq(described_class.new('key2' => 'value2')) + end + end + + context 'when no changes are made' do + let(:result) do + document.reject! { |_k, _v| false } + end + + it 'returns nil' do + expect(result).to be_nil + end + + it 'does not modify the original document' do + result + expect(document).to eq(described_class.new('key1' => 'value1', 'key2' => 'value2', 'key3' => 'value3')) + end + end + + context 'when block not given' do + let(:enumerator) do + document.dup.reject! + end + + it 'returns an enumerator' do + expect(enumerator).to be_a(Enumerator) + end + + it 'enumerates over all key-value pairs' do + pairs = enumerator.to_a + expect(pairs.length).to eq(document.length) + expect(pairs.map(&:first)).to eq(document.keys) + expect(pairs.map(&:last)).to eq(document.values) + end + + it 'modifies the original document when used with a block' do + result = document.reject!.each { |key, value| key == 'key1' || value == 'value3' } + expect(result).to be(document) + expect(document).to eq(described_class.new('key2' => 'value2')) + end + + it 'returns nil if no changes are made' do + result = document.reject!.each { |_key, _value| false } + expect(result).to be_nil + expect(document).to eq(described_class.new('key1' => 'value1', 'key2' => 'value2', 'key3' => 'value3')) + end + end + end + + describe '#delete_if' do + context 'when changes are made' do + let(:result) do + document.delete_if { |k, v| k == 'key1' || v == 'value3' } + end + + it 'returns self' do + expect(result).to be(document) + end + + it 'modifies the original document' do + result + expect(document).to eq(described_class.new('key2' => 'value2')) + end + end + + context 'when no changes are made' do + let(:result) do + document.delete_if { |_k, _v| false } + end + + it 'returns nil' do + expect(result).to be(document) + end + + it 'does not modify the original document' do + result + expect(document).to eq(described_class.new('key1' => 'value1', 'key2' => 'value2', 'key3' => 'value3')) + end + end + + context 'when block not given' do + let(:enumerator) do + document.dup.delete_if + end + + it 'returns an enumerator' do + expect(enumerator).to be_a(Enumerator) + end + + it 'enumerates over all key-value pairs' do + pairs = enumerator.to_a + expect(pairs.length).to eq(document.length) + expect(pairs.map(&:first)).to eq(document.keys) + expect(pairs.map(&:last)).to eq(document.values) + end + + it 'modifies the original document when used with a block' do + result = document.delete_if.each { |key, value| key == 'key1' || value == 'value3' } + expect(result).to be(document) + expect(document).to eq(described_class.new('key2' => 'value2')) + end + + it 'returns self if no changes are made' do + result = document.delete_if.each { |_key, _value| false } + expect(result).to be(document) + end + end + end + + describe '#select' do + let(:result) do + document.select { |k, v| k == 'key1' || v == 'value3' } + end + + it 'returns a new BSON::Document' do + expect(result).to be_a(described_class) + expect(result).not_to be(document) + end + + it 'includes keys for which the block returns true' do + expect(result).to eq(described_class.new('key1' => 'value1', 'key3' => 'value3')) + end + + it 'does not modify the original document' do + result + expect(document).to eq(described_class.new('key1' => 'value1', 'key2' => 'value2', 'key3' => 'value3')) + end + + context 'when no changes are made' do + let(:result) do + document.select { |_k, _v| true } + end + + it 'returns a new BSON::Document' do + expect(result).to be_a(described_class) + expect(result).not_to be(document) + end + + it 'returns all original keys and values' do + expect(result).to eq(described_class.new('key1' => 'value1', 'key2' => 'value2', 'key3' => 'value3')) + end + + it 'does not modify the original document' do + result + expect(document).to eq(described_class.new('key1' => 'value1', 'key2' => 'value2', 'key3' => 'value3')) + end + end + + context 'when block not given' do + let(:enumerator) do + document.select + end + + it 'returns an enumerator' do + expect(enumerator).to be_a(Enumerator) + end + + it 'enumerates over all key-value pairs' do + pairs = enumerator.to_a + expect(pairs.length).to eq(document.length) + expect(pairs.map(&:first)).to eq(document.keys) + expect(pairs.map(&:last)).to eq(document.values) + end + + it 'produces a BSON::Document when used with a block' do + result = enumerator.each { |key, value| key == 'key1' || value == 'value3' } + expect(result).to be_a(described_class) + expect(result).to eq(described_class.new('key1' => 'value1', 'key3' => 'value3')) + end + end + end + + describe '#select!' do + context 'when changes are made' do + let(:result) do + document.select! { |k, v| k == 'key1' || v == 'value3' } + end + + it 'returns self' do + expect(result).to be(document) + end + + it 'modifies the original document' do + result + expect(document).to eq(described_class.new('key1' => 'value1', 'key3' => 'value3')) + end + end + + context 'when no changes are made' do + let(:result) do + document.select! { |_k, _v| true } + end + + it 'returns nil' do + expect(result).to be_nil + end + + it 'does not modify the original document' do + result + expect(document).to eq(described_class.new('key1' => 'value1', 'key2' => 'value2', 'key3' => 'value3')) + end + end + + context 'when block not given' do + let(:enumerator) do + document.dup.select! + end + + it 'returns an enumerator' do + expect(enumerator).to be_a(Enumerator) + end + + it 'enumerates over all key-value pairs' do + pairs = enumerator.to_a + expect(pairs.length).to eq(document.length) + expect(pairs.map(&:first)).to eq(document.keys) + expect(pairs.map(&:last)).to eq(document.values) + end + + it 'modifies the original document when used with a block' do + result = document.select!.each { |key, value| key == 'key1' || value == 'value3' } + expect(result).to be(document) + expect(document).to eq(described_class.new('key1' => 'value1', 'key3' => 'value3')) + end + + it 'returns nil if no changes are made' do + result = document.select!.each { |_key, _value| true } + expect(result).to be_nil + expect(document).to eq(document) + end + end + end + + describe '#filter' do + it 'is an alias for select' do + expect(document.method(:filter)).to eq(document.method(:select)) + end + + context 'when block not given' do + let(:enumerator) do + document.filter + end + + it 'is an alias for select' do + expect(document.method(:filter)).to eq(document.method(:select)) + end + + it 'returns an enumerator' do + expect(enumerator).to be_a(Enumerator) + end + + it 'produces a BSON::Document when used with a block' do + result = enumerator.each { |key, value| key == 'key1' || value == 'value3' } + expect(result).to be_a(described_class) + expect(result).to eq(described_class.new('key1' => 'value1', 'key3' => 'value3')) + end + end + end + + describe '#filter!' do + it 'is an alias for select!' do + expect(document.method(:filter!)).to eq(document.method(:select!)) + end + end + + describe '#keep_if' do + context 'when changes are made' do + let(:result) do + document.keep_if { |k, v| k == 'key1' || v == 'value3' } + end + + it 'returns self' do + expect(result).to be(document) + end + + it 'modifies the original document' do + result + expect(document).to eq(described_class.new('key1' => 'value1', 'key3' => 'value3')) + end + end + + context 'when no changes are made' do + let(:result) do + document.keep_if { |_k, _v| true } + end + + it 'returns self' do + expect(result).to be(document) + end + + it 'does not modify the original document' do + result + expect(document).to eq(described_class.new('key1' => 'value1', 'key2' => 'value2', 'key3' => 'value3')) + end + end + + context 'when block not given' do + let(:enumerator) do + document.dup.keep_if + end + + it 'returns an enumerator' do + expect(enumerator).to be_a(Enumerator) + end + + it 'enumerates over all key-value pairs' do + pairs = enumerator.to_a + expect(pairs.length).to eq(document.length) + expect(pairs.map(&:first)).to eq(document.keys) + expect(pairs.map(&:last)).to eq(document.values) + end + + it 'modifies the original document when used with a block' do + result = document.keep_if.each { |key, value| key == 'key1' || value == 'value3' } + expect(result).to be(document) + expect(document).to eq(described_class.new('key1' => 'value1', 'key3' => 'value3')) + end + + it 'returns self if no changes are made' do + result = document.keep_if.each { |_key, _value| true } + expect(result).to eq(document) + end + end + end + + describe '#compact' do + let(:document) do + described_class.new('key1' => 'value1', 'key2' => nil, 'key3' => 'value3') + end + + let(:result) do + document.compact + end + + it 'returns a new BSON::Document' do + expect(result).to be_a(described_class) + expect(result).not_to be(document) + end + + it 'excludes pairs with nil values' do + expect(result).to eq(described_class.new('key1' => 'value1', 'key3' => 'value3')) + end + + it 'does not modify the original document' do + result + expect(document).to eq(described_class.new('key1' => 'value1', 'key2' => nil, 'key3' => 'value3')) + end + end + + describe '#compact!' do + let(:document) do + described_class.new('key1' => 'value1', 'key2' => nil, 'key3' => 'value3') + end + + context 'when there are nil values' do + let(:result) do + document.compact! + end + + it 'returns self' do + expect(result).to be(document) + end + + it 'modifies the original document' do + result + expect(document).to eq(described_class.new('key1' => 'value1', 'key3' => 'value3')) + end + end + + context 'when there are no nil values' do + let(:document) do + described_class.new('key1' => 'value1', 'key3' => 'value3') + end + + let(:result) do + document.compact! + end + + it 'returns nil' do + expect(result).to be_nil + end + + it 'does not modify the original document' do + result + expect(document).to eq(described_class.new('key1' => 'value1', 'key3' => 'value3')) + end + end + end + + describe '#slice' do + context 'with a single-level document' do + + let(:result) do + document.slice('key1', 'key3') + end + + it 'returns a new BSON::Document' do + expect(result).to be_a(described_class) + expect(result).not_to be(document) + end + + it 'includes only the specified keys' do + expect(result).to eq(described_class.new('key1' => 'value1', 'key3' => 'value3')) + end + + it 'does not modify the original document' do + result + expect(document).to eq(described_class.new('key1' => 'value1', 'key2' => 'value2', 'key3' => 'value3')) + end + end + + context 'when some keys do not exist' do + let(:document) do + described_class.new('key1' => 'value1', 'key2' => 'value2') + end + + let(:result) do + document.slice('key1', 'nonexistent') + end + + it 'includes only the existing keys' do + expect(result).to eq(described_class.new('key1' => 'value1')) + end + end + + context 'with symbol keys' do + let(:document) do + described_class.new(key1: 'value1', key2: 'value2') + end + + let(:result) do + document.slice(:key1) + end + + it 'handles symbol keys correctly' do + expect(result).to eq(described_class.new('key1' => 'value1')) + end + end + end + + describe '#transform_keys' do + let(:result) do + document.transform_keys { |key| key.upcase } + end + + it 'returns a new BSON::Document' do + expect(result).to be_a(described_class) + expect(result).not_to be(document) + end + + it 'transforms all keys according to the block' do + expect(result).to eq({ 'KEY1' => 'value1', 'KEY2' => 'value2', 'KEY3' => 'value3' }) + end + + it 'does not modify the original document' do + result + expect(document).to eq(described_class.new('key1' => 'value1', 'key2' => 'value2', 'key3' => 'value3')) + end + + context 'with nested documents' do + let(:document) do + described_class.new('outer' => described_class.new('inner' => 'value')) + end + + let(:result) do + document.transform_keys { |key| key.upcase } + end + + it 'does not transform keys in nested documents' do + expect(result).to eq({ 'OUTER' => { 'inner' => 'value' } }) + end + + it 'keeps nested elements as BSON::Document' do + expect(result['OUTER']).to be_a(described_class) + end + end + + context 'when block not given' do + let(:enumerator) do + document.transform_keys + end + + it 'returns an enumerator' do + expect(enumerator).to be_a(Enumerator) + end + + it 'enumerates over all keys' do + expect(enumerator.to_a).to eq(document.keys) + end + + it 'produces a BSON::Document when used with a block' do + result = enumerator.each { |key| key.upcase } + expect(result).to be_a(described_class) + expect(result).to eq(described_class.new('KEY1' => 'value1', 'KEY2' => 'value2', 'KEY3' => 'value3')) + end + end + end + + describe '#transform_keys!' do + let(:result) do + document.transform_keys! { |key| key.upcase } + end + + it 'returns self' do + expect(result).to be(document) + end + + it 'transforms all keys according to the block' do + result + expect(document.keys).to eq(%w[KEY1 KEY2 KEY3]) + end + + context 'with nested documents' do + let(:document) do + described_class.new('outer' => described_class.new('inner' => 'value')) + end + + let(:result) do + document.transform_keys! { |key| key.upcase } + end + + it 'does not transform keys in nested documents' do + result + expect(document['OUTER'].keys).to eq(['inner']) + end + + it 'preserves nested BSON::Document' do + result + expect(document['OUTER']).to be_a(described_class) + end + end + + context 'transforming to keys to String' do + let(:document) do + described_class.new('key' => :a, 1 => :b) + end + + let(:action) do + document.transform_keys!(&:to_s) + end + + it 'transforms keys to String' do + action + expect(document).to eq('key' => :a, '1' => :b) + end + end + + context 'transforming to keys to Symbol' do + let(:document) do + described_class.new('key' => :a, 1 => :b) + end + + let(:action) do + document.transform_keys! { |key| key.is_a?(String) ? key.to_sym : key } + end + + it 'transforms keys to String' do + action + expect(document).to eq('key' => :a, 1 => :b) + end + end + + context 'when block not given' do + let(:enumerator) do + document.transform_keys! + end + + it 'returns an enumerator' do + expect(enumerator).to be_a(Enumerator) + end + + it 'enumerates over all keys' do + expect(enumerator.to_a).to eq(%w[key1 key2 key3]) + + # Side effect of calling #to_a, same as behavior on Hash. + expect(document).to eq(nil => 'value3') + end + + it 'modifies the original document when used with a block' do + result = document.transform_keys!.each { |key| key.upcase } + expect(result).to be(document) + expect(document).to eq(described_class.new('KEY1' => 'value1', 'KEY2' => 'value2', 'KEY3' => 'value3')) + end + end + end + + describe '#transform_values' do + let(:result) do + document.transform_values { |value| value.upcase } + end + + it 'returns a new BSON::Document' do + expect(result).to be_a(described_class) + expect(result).not_to be(document) + end + + it 'transforms all values according to the block' do + expect(result).to eq({ 'key1' => 'VALUE1', 'key2' => 'VALUE2', 'key3' => 'VALUE3' }) + end + + it 'does not modify the original document' do + result + expect(document).to eq(described_class.new('key1' => 'value1', 'key2' => 'value2', 'key3' => 'value3')) + end + + context 'with nested documents' do + let(:document) do + described_class.new('key1' => described_class.new('inner' => 'value')) + end + + let(:result) do + document.transform_values { |value| value } + end + + it 'preserves nested documents' do + original_nested = document['key1'] + nested = result['key1'] + expect(nested).to be_a(described_class) + expect(nested).to eq(original_nested) + end + end + + context 'transforming nested documents' do + let(:document) do + described_class.new('key1' => described_class.new('inner' => 'value')) + end + + let(:result) do + document.transform_values! { |value| value.is_a?(described_class) ? { foo: :bar, 1 => :a } : value } + end + + it 'allows transforming nested documents' do + expect(result).to eq(described_class.new('key1' => { 'foo' => :bar, 1 => :a })) + end + + it 'converts nested values to BSON::Document' do + nested = result['key1'] + expect(nested).to be_a(described_class) + expect(nested.keys).to eq(['foo', 1]) + end + end + + context 'when block not given' do + let(:enumerator) do + document.transform_values + end + + it 'returns an enumerator' do + expect(enumerator).to be_a(Enumerator) + end + + it 'enumerates over all values' do + expect(enumerator.to_a).to eq(document.values) + end + + it 'produces a BSON::Document when used with a block' do + result = enumerator.each { |value| value.upcase } + expect(result).to be_a(described_class) + expect(result).to eq(described_class.new('key1' => 'VALUE1', 'key2' => 'VALUE2', 'key3' => 'VALUE3')) + end + + context 'with nested documents' do + let(:document) do + described_class.new('key1' => 'value1', 'nested' => described_class.new('inner' => 'value')) + end + + let(:nested_enumerator) do + document.transform_values + end + + it 'properly handles nested documents when used with a block' do + result = nested_enumerator.each { |value| value.is_a?(described_class) ? value.dup : value } + expect(result).to be_a(described_class) + expect(result['nested']).to be_a(described_class) + expect(result['nested']).to eq(described_class.new('inner' => 'value')) + end + end + + context 'enumerator for nested documents' do + let(:document) do + described_class.new( + 'key1' => 'value1', + 'nested' => described_class.new( + 'inner1' => 'value2', + 'inner2' => described_class.new('deep' => 'value3') + ) + ) + end + + it 'preserves class of nested documents in transformations' do + # Using transform_values with an identity block should preserve all types + result = document.transform_values { |v| v } + expect(result['nested']).to be_a(described_class) + expect(result['nested']['inner2']).to be_a(described_class) + end + + it 'allows transforming nested documents with enumerator' do + document.transform_values!.each { |v| v } + expect(document['nested']).to be_a(described_class) + expect(document['nested']['inner2']).to be_a(described_class) + end + end + + context 'chaining enumerators' do + it 'allows chaining operations on the returned enumerator' do + result = document.transform_values.with_index do |value, i| + "#{value}-#{i}" + end + + expect(result).to be_a(described_class) + expect(result).to eq(described_class.new( + 'key1' => 'value1-0', + 'key2' => 'value2-1', + 'key3' => 'value3-2' + )) + end + end + end + end + + describe '#transform_values!' do + let(:result) do + document.transform_values! { |value| value.upcase } + end + + it 'returns self' do + expect(result).to be(document) + end + + it 'transforms all values according to the block' do + result + expect(document).to eq(described_class.new('key1' => 'VALUE1', 'key2' => 'VALUE2', 'key3' => 'VALUE3')) + end + + context 'with nested documents' do + let(:document) do + described_class.new('key1' => described_class.new('inner' => 'value')) + end + + let(:action) do + document.transform_values! { |value| value } + end + + it 'preserves nested documents' do + original_nested = document['key1'] + action + nested = document['key1'] + expect(nested).to be_a(described_class) + expect(nested).to eq(original_nested) + end + end + + context 'transforming nested documents' do + let(:document) do + described_class.new('key1' => described_class.new('inner' => 'value')) + end + + let(:action) do + document.transform_values! { |value| value.is_a?(described_class) ? { foo: :bar, 1 => :a } : value } + end + + it 'allows transforming nested documents' do + action + expect(document).to eq(described_class.new('key1' => { 'foo' => :bar, 1 => :a })) + end + + it 'converts nested values to BSON::Document' do + action + nested = document['key1'] + expect(nested).to be_a(described_class) + expect(nested.keys).to eq(['foo', 1]) + end + end + + context 'when block not given' do + let(:enumerator) do + document.dup.transform_values! + end + + it 'returns an enumerator' do + expect(enumerator).to be_a(Enumerator) + end + + it 'enumerates over all values' do + expect(enumerator.to_a).to eq(document.values) + end + + it 'modifies the original document when used with a block' do + result = document.transform_values!.each { |value| value.upcase } + expect(result).to be(document) + expect(document).to eq(described_class.new('key1' => 'VALUE1', 'key2' => 'VALUE2', 'key3' => 'VALUE3')) + end + end + end +end diff --git a/spec/bson/document_spec.rb b/spec/bson/document_spec.rb index 59102b019..95254d411 100644 --- a/spec/bson/document_spec.rb +++ b/spec/bson/document_spec.rb @@ -185,61 +185,57 @@ end end - if described_class.instance_methods.include?(:dig) - describe "#dig" do - let(:document) do - described_class.new("key1" => { :key2 => "value" }) - end + describe "#dig" do + let(:document) do + described_class.new("key1" => { :key2 => "value" }) + end - context "when provided string keys" do + context "when provided string keys" do - it "returns the value" do - expect(document.dig("key1", "key2")).to eq("value") - end + it "returns the value" do + expect(document.dig("key1", "key2")).to eq("value") end + end - context "when provided symbol keys" do + context "when provided symbol keys" do - it "returns the value" do - expect(document.dig(:key1, :key2)).to eq("value") - end + it "returns the value" do + expect(document.dig(:key1, :key2)).to eq("value") end end end - if described_class.instance_methods.include?(:slice) - describe "#slice" do - let(:document) do - described_class.new("key1" => "value1", key2: "value2") - end + describe "#slice" do + let(:document) do + described_class.new("key1" => "value1", key2: "value2") + end - context "when provided string keys" do + context "when provided string keys" do - it "is a BSON Document" do - expect(document.slice("key1")).to be_a(BSON::Document) - end + it "is a BSON Document" do + expect(document.slice("key1")).to be_a(BSON::Document) + end - it "returns the partial document" do - expect(document.slice("key1")).to contain_exactly(['key1', 'value1']) - end + it "returns the partial document" do + expect(document.slice("key1")).to contain_exactly(['key1', 'value1']) end + end - context "when provided symbol keys" do + context "when provided symbol keys" do - it "is a BSON Document" do - expect(document.slice(:key1)).to be_a(BSON::Document) - end + it "is a BSON Document" do + expect(document.slice(:key1)).to be_a(BSON::Document) + end - it "returns the partial document" do - expect(document.slice(:key1)).to contain_exactly(['key1', 'value1']) - end + it "returns the partial document" do + expect(document.slice(:key1)).to contain_exactly(['key1', 'value1']) end + end - context "when provided keys that do not exist in the document" do + context "when provided keys that do not exist in the document" do - it "returns only the keys that exist in the document" do - expect(document.slice(:key1, :key3)).to contain_exactly(['key1', 'value1']) - end + it "returns only the keys that exist in the document" do + expect(document.slice(:key1, :key3)).to contain_exactly(['key1', 'value1']) end end end @@ -264,7 +260,6 @@ end end - describe "#delete" do shared_examples_for "a document with deletable pairs" do From e9604340e5f8febfc75d1b657f90b2b8e02f59aa Mon Sep 17 00:00:00 2001 From: johnnyshields <27655+johnnyshields@users.noreply.github.com> Date: Mon, 5 May 2025 20:29:37 +0900 Subject: [PATCH 2/6] Fix rubocop --- .rubocop.yml | 6 ++ spec/bson/document_as_spec.rb | 37 ++++++------ spec/bson/document_native_spec.rb | 97 +++++++++++++++---------------- 3 files changed, 73 insertions(+), 67 deletions(-) diff --git a/.rubocop.yml b/.rubocop.yml index 89ae7eb08..cc2849fb0 100644 --- a/.rubocop.yml +++ b/.rubocop.yml @@ -72,11 +72,17 @@ Metrics/MethodLength: RSpec/BeforeAfterAll: Enabled: false +RSpec/ContextWording: + Enabled: false + # Ideally, we'd use this one, too, but our tests have not historically followed # this style and it's not worth changing right now, IMO RSpec/DescribeClass: Enabled: false +RSpec/ExampleLength: + Enabled: false + Style/FetchEnvVar: Enabled: false diff --git a/spec/bson/document_as_spec.rb b/spec/bson/document_as_spec.rb index dda7e6654..362245c02 100644 --- a/spec/bson/document_as_spec.rb +++ b/spec/bson/document_as_spec.rb @@ -1,4 +1,5 @@ # frozen_string_literal: true + # Copyright (C) 2021 MongoDB Inc. # # Licensed under the Apache License, Version 2.0 (the "License"); @@ -113,7 +114,7 @@ it 'returns a new BSON::Document' do expect(result).to be_a(described_class) - expect(result).to_not be(document) + expect(result).not_to be(document) end it 'modifies only the top-level document keys' do @@ -369,9 +370,9 @@ let(:document) do described_class.new( 'key1' => 'value1', - 'array' => [1, 2, 3], + 'array' => [ 1, 2, 3 ], 'nested' => described_class.new( - 'array' => [4, 5, 6] + 'array' => [ 4, 5, 6 ] ) ) end @@ -379,9 +380,9 @@ let(:other) do { 'key2' => 'value2', - 'array' => [7, 8, 9], + 'array' => [ 7, 8, 9 ], 'nested' => { - 'array' => [10, 11, 12] + 'array' => [ 10, 11, 12 ] } } end @@ -391,8 +392,8 @@ end it 'replaces arrays instead of merging them' do - expect(result['array']).to eq([7, 8, 9]) - expect(result['nested']['array']).to eq([10, 11, 12]) + expect(result['array']).to eq([ 7, 8, 9 ]) + expect(result['nested']['array']).to eq([ 10, 11, 12 ]) end end @@ -586,9 +587,9 @@ let(:document) do described_class.new( 'key1' => 'value1', - 'array' => [1, 2, 3], + 'array' => [ 1, 2, 3 ], 'nested' => described_class.new( - 'array' => [4, 5, 6] + 'array' => [ 4, 5, 6 ] ) ) end @@ -596,9 +597,9 @@ let(:other) do { 'key2' => 'value2', - 'array' => [7, 8, 9], + 'array' => [ 7, 8, 9 ], 'nested' => { - 'array' => [10, 11, 12] + 'array' => [ 10, 11, 12 ] } } end @@ -609,8 +610,8 @@ it 'replaces arrays instead of merging them' do result - expect(document['array']).to eq([7, 8, 9]) - expect(document['nested']['array']).to eq([10, 11, 12]) + expect(document['array']).to eq([ 7, 8, 9 ]) + expect(document['nested']['array']).to eq([ 10, 11, 12 ]) end end @@ -845,9 +846,9 @@ it 'does not modify the original document' do result - expect(document).to eq(described_class.new( - 'key1' => 'value1', 'key2' => 'value2', 'key3' => 'value3' - )) + expect(document).to eq( + described_class.new('key1' => 'value1', 'key2' => 'value2', 'key3' => 'value3') + ) end end @@ -932,7 +933,7 @@ end it 'removes blank values' do - expect(result.keys).to eq(['key1']) + expect(result.keys).to eq([ 'key1' ]) expect(result['key1']).to eq('value1') end @@ -986,7 +987,7 @@ it 'removes blank values' do result - expect(document.keys).to eq(['key1']) + expect(document.keys).to eq([ 'key1' ]) expect(document['key1']).to eq('value1') end end diff --git a/spec/bson/document_native_spec.rb b/spec/bson/document_native_spec.rb index cbc038af2..b08a4a231 100644 --- a/spec/bson/document_native_spec.rb +++ b/spec/bson/document_native_spec.rb @@ -1,4 +1,5 @@ # frozen_string_literal: true + # Copyright (C) 2009-2020 MongoDB Inc. # # Licensed under the Apache License, Version 2.0 (the "License"); @@ -108,7 +109,7 @@ it 'returns a Hash' do expect(hash).to be_a(Hash) - expect(hash).to_not be_a(described_class) + expect(hash).not_to be_a(described_class) end it 'returns a hash with the same keys and values' do @@ -128,7 +129,7 @@ it 'converts nested documents to hashes' do nested = hash['key2'] expect(nested).to be_a(Hash) - expect(nested).to_not be_a(described_class) + expect(nested).not_to be_a(described_class) end it 'preserves the nested structure' do @@ -142,12 +143,12 @@ end let(:hash) do - document.to_h { |k, v| [k.to_sym, v.upcase] } + document.to_h { |k, v| [ k.to_sym, v.upcase ] } end it 'returns a Hash' do expect(hash).to be_a(Hash) - expect(hash).to_not be_a(described_class) + expect(hash).not_to be_a(described_class) end it 'applies the block to each key-value pair' do @@ -161,23 +162,24 @@ described_class.new('key1' => 'value1', 'key2' => { 'key3' => 'value3' }) end + let(:hash) do + document.to_hash + end + it 'is an alias for #to_h' do expect(document.method(:to_hash)).to eq(document.method(:to_h)) end - let(:hash) do - document.to_hash - end it 'returns a Hash' do expect(hash).to be_a(Hash) - expect(hash).to_not be_a(described_class) + expect(hash).not_to be_a(described_class) end it 'converts nested documents' do nested = hash['key2'] expect(nested).to be_a(Hash) - expect(nested).to_not be_a(described_class) + expect(nested).not_to be_a(described_class) end it 'contains the correct keys and values' do @@ -232,7 +234,7 @@ context 'with array values containing hashes' do let(:result) do - document['array'] = [1, 2, { 'a' => 1 }] + document['array'] = [ 1, 2, { 'a' => 1 } ] end it 'converts hashes within arrays to BSON::Document' do @@ -284,19 +286,19 @@ describe '#has_key?' do context 'with existing string keys' do it 'returns true' do - expect(document.has_key?('key1')).to be true + expect(document.key?('key1')).to be true end end context 'with existing symbol keys' do it 'returns true' do - expect(document.has_key?(:key1)).to be true + expect(document.key?(:key1)).to be true end end context 'with non-existent keys' do it 'returns false' do - expect(document.has_key?('non_existent')).to be false + expect(document.key?('non_existent')).to be false end end end @@ -460,7 +462,7 @@ describe '#has_value?' do context 'with existing values' do it 'returns true' do - expect(document.has_value?('value1')).to be true + expect(document.value?('value1')).to be true end end @@ -470,13 +472,13 @@ end it 'returns true when searching with a symbol' do - expect(document_with_symbols.has_value?(:symbol_value)).to be true + expect(document_with_symbols.value?(:symbol_value)).to be true end end context 'with non-existent values' do it 'returns false' do - expect(document.has_value?('non_existent')).to be false + expect(document.value?('non_existent')).to be false end end end @@ -514,7 +516,7 @@ end it 'returns nil for missing keys' do - expect(values).to eq(['value1', nil]) + expect(values).to eq([ 'value1', nil ]) end end @@ -577,7 +579,7 @@ end it 'returns the key-value pair' do - expect(result).to eq(['key1', 'value1']) + expect(result).to eq(%w[key1 value1]) end end @@ -591,7 +593,7 @@ end it 'returns the first matching pair' do - expect(result).to eq(['key1', 'duplicate']) + expect(result).to eq(%w[key1 duplicate]) end end @@ -616,7 +618,7 @@ end it 'finds the key-value pair' do - expect(result).to eq(['key1', :symbol_value]) + expect(result).to eq([ 'key1', :symbol_value ]) end end @@ -645,7 +647,7 @@ end it 'can find BSON::Document values' do - expect(result).to eq(['key1', nested_doc]) + expect(result).to eq([ 'key1', nested_doc ]) end context 'when searching with an equivalent hash' do @@ -654,7 +656,7 @@ end it 'finds the pair by equivalent hash' do - expect(result).to eq(['key1', { 'inner' => 'value' }]) + expect(result).to eq([ 'key1', { 'inner' => 'value' } ]) end end end @@ -683,9 +685,9 @@ context 'with missing keys and no block' do it 'raises KeyError for missing keys' do - expect { + expect do document.fetch_values('key1', 'missing') - }.to raise_error(KeyError) + end.to raise_error(KeyError) end end @@ -1194,7 +1196,7 @@ end it 'returns nil if no changes are made' do - result = document.reject!.each { |_key, _value| false } + result = document.reject!.each { |_key, _value| false } # rubocop:disable Lint/Void expect(result).to be_nil expect(document).to eq(described_class.new('key1' => 'value1', 'key2' => 'value2', 'key3' => 'value3')) end @@ -1255,7 +1257,7 @@ end it 'returns self if no changes are made' do - result = document.delete_if.each { |_key, _value| false } + result = document.delete_if.each { |_key, _value| false } # rubocop:disable Lint/Void expect(result).to be(document) end end @@ -1378,9 +1380,9 @@ end it 'returns nil if no changes are made' do - result = document.select!.each { |_key, _value| true } + result = document.select!.each { |_key, _value| true } # rubocop:disable Lint/Void expect(result).to be_nil - expect(document).to eq(document) + expect(document).to eq('key1' => 'value1', 'key2' => 'value2', 'key3' => 'value3') end end end @@ -1471,7 +1473,7 @@ end it 'returns self if no changes are made' do - result = document.keep_if.each { |_key, _value| true } + result = document.keep_if.each { |_key, _value| true } # rubocop:disable Lint/Void expect(result).to eq(document) end end @@ -1543,7 +1545,6 @@ describe '#slice' do context 'with a single-level document' do - let(:result) do document.slice('key1', 'key3') end @@ -1594,7 +1595,7 @@ describe '#transform_keys' do let(:result) do - document.transform_keys { |key| key.upcase } + document.transform_keys(&:upcase) end it 'returns a new BSON::Document' do @@ -1617,7 +1618,7 @@ end let(:result) do - document.transform_keys { |key| key.upcase } + document.transform_keys(&:upcase) end it 'does not transform keys in nested documents' do @@ -1643,7 +1644,7 @@ end it 'produces a BSON::Document when used with a block' do - result = enumerator.each { |key| key.upcase } + result = enumerator.each(&:upcase) expect(result).to be_a(described_class) expect(result).to eq(described_class.new('KEY1' => 'value1', 'KEY2' => 'value2', 'KEY3' => 'value3')) end @@ -1652,7 +1653,7 @@ describe '#transform_keys!' do let(:result) do - document.transform_keys! { |key| key.upcase } + document.transform_keys!(&:upcase) end it 'returns self' do @@ -1670,12 +1671,12 @@ end let(:result) do - document.transform_keys! { |key| key.upcase } + document.transform_keys!(&:upcase) end it 'does not transform keys in nested documents' do result - expect(document['OUTER'].keys).to eq(['inner']) + expect(document['OUTER'].keys).to eq([ 'inner' ]) end it 'preserves nested BSON::Document' do @@ -1731,7 +1732,7 @@ end it 'modifies the original document when used with a block' do - result = document.transform_keys!.each { |key| key.upcase } + result = document.transform_keys!.each(&:upcase) expect(result).to be(document) expect(document).to eq(described_class.new('KEY1' => 'value1', 'KEY2' => 'value2', 'KEY3' => 'value3')) end @@ -1740,7 +1741,7 @@ describe '#transform_values' do let(:result) do - document.transform_values { |value| value.upcase } + document.transform_values(&:upcase) end it 'returns a new BSON::Document' do @@ -1790,7 +1791,7 @@ it 'converts nested values to BSON::Document' do nested = result['key1'] expect(nested).to be_a(described_class) - expect(nested.keys).to eq(['foo', 1]) + expect(nested.keys).to eq([ 'foo', 1 ]) end end @@ -1808,7 +1809,7 @@ end it 'produces a BSON::Document when used with a block' do - result = enumerator.each { |value| value.upcase } + result = enumerator.each(&:upcase) expect(result).to be_a(described_class) expect(result).to eq(described_class.new('key1' => 'VALUE1', 'key2' => 'VALUE2', 'key3' => 'VALUE3')) end @@ -1849,7 +1850,7 @@ end it 'allows transforming nested documents with enumerator' do - document.transform_values!.each { |v| v } + document.transform_values!.each { |v| v } # rubocop:disable Lint/Void expect(document['nested']).to be_a(described_class) expect(document['nested']['inner2']).to be_a(described_class) end @@ -1862,11 +1863,9 @@ end expect(result).to be_a(described_class) - expect(result).to eq(described_class.new( - 'key1' => 'value1-0', - 'key2' => 'value2-1', - 'key3' => 'value3-2' - )) + expect(result).to eq( + described_class.new('key1' => 'value1-0', 'key2' => 'value2-1', 'key3' => 'value3-2') + ) end end end @@ -1874,7 +1873,7 @@ describe '#transform_values!' do let(:result) do - document.transform_values! { |value| value.upcase } + document.transform_values!(&:upcase) end it 'returns self' do @@ -1922,7 +1921,7 @@ action nested = document['key1'] expect(nested).to be_a(described_class) - expect(nested.keys).to eq(['foo', 1]) + expect(nested.keys).to eq([ 'foo', 1 ]) end end @@ -1940,7 +1939,7 @@ end it 'modifies the original document when used with a block' do - result = document.transform_values!.each { |value| value.upcase } + result = document.transform_values!.each(&:upcase) expect(result).to be(document) expect(document).to eq(described_class.new('key1' => 'VALUE1', 'key2' => 'VALUE2', 'key3' => 'VALUE3')) end From e8fd5c77478536b450a09ab2542f5e9bd56f34bf Mon Sep 17 00:00:00 2001 From: johnnyshields <27655+johnnyshields@users.noreply.github.com> Date: Mon, 5 May 2025 21:46:48 +0900 Subject: [PATCH 3/6] Fix #to_h and #to_hash to match ActiveSupport::HashWithIndifferentAccess conventions. --- lib/bson/document.rb | 32 ++-- spec/bson/document_native_spec.rb | 240 +++++++++++++++++++++++++++--- 2 files changed, 243 insertions(+), 29 deletions(-) diff --git a/lib/bson/document.rb b/lib/bson/document.rb index 863f8e7eb..50dcdd57b 100644 --- a/lib/bson/document.rb +++ b/lib/bson/document.rb @@ -356,21 +356,22 @@ def except(*keys) # Recursively converts the document and all nested documents to a hash. # - # Accepts an optional block, which is applied to the newly converted hash. - # This is done to mimic the Ruby kernel object behavior of #to_h. - # - # @yield [ key, value ] Optional block for transforming the hash. + # @note #to_h only converts the top-level document to a hash. #to_hash + # converts all nested documents to hashes as well. This follows the + # convention of ActiveSupport::HashWithIndifferentAccess # # @return [ Hash ] A new hash object, containing nested hashes if applicable. - def to_h(&block) - hash = super do |key, value| - [key, value.is_a?(self.class) ? value.to_h : value] + # + # @note Code lovingly borrowed from ActiveSupport::HashWithIndifferentAccess. + def to_hash + ::Hash.new.tap do |hash| + set_defaults(hash) + each do |key, value| + hash[key] = value.is_a?(self.class) ? value.to_hash : value + end end - block_given? ? hash.send(:to_h, &block) : hash end - alias :to_hash :to_h - # Inverts the document by using values as keys and vice versa. # # @return [ BSON::Document ] A new document with keys and values switched. @@ -472,7 +473,7 @@ def symbolize_keys! # # @return [ Hash ] A new hash with all keys as symbols. def deep_symbolize_keys - to_h.deep_symbolize_keys! + to_hash.deep_symbolize_keys! end # Raises an error because BSON::Document enforces string keys internally, @@ -505,5 +506,14 @@ def convert_key(key) def convert_value(value) value.to_bson_normalized_value end + + # @note Code lovingly borrowed from ActiveSupport::HashWithIndifferentAccess. + def set_defaults(target) + if default_proc + target.default_proc = default_proc.dup + else + target.default = default + end + end end end diff --git a/spec/bson/document_native_spec.rb b/spec/bson/document_native_spec.rb index b08a4a231..4954fef05 100644 --- a/spec/bson/document_native_spec.rb +++ b/spec/bson/document_native_spec.rb @@ -126,10 +126,9 @@ document.to_h end - it 'converts nested documents to hashes' do + it 'does not convert nested documents to hashes' do nested = hash['key2'] - expect(nested).to be_a(Hash) - expect(nested).not_to be_a(described_class) + expect(nested).to be_a(described_class) end it 'preserves the nested structure' do @@ -155,35 +154,240 @@ expect(hash).to eq({ key1: 'VALUE1', key2: 'VALUE2' }) end end + + context 'with a static default' do + let(:document) do + doc = described_class.new('key1' => 'value1', 'key2' => 'value2') + doc.default = 'default_value' + doc + end + + let(:hash) do + document.to_h + end + + it 'returns a Hash' do + expect(hash).to be_a(Hash) + expect(hash).not_to be_a(described_class) + end + + it 'returns a hash with the same keys and values' do + expect(hash).to eq({ 'key1' => 'value1', 'key2' => 'value2' }) + end + + it 'transfers the default value to the resulting hash' do + expect(hash.default).to eq('default_value') + end + + it 'allows accessing the default with a non-existent key' do + expect(hash['non_existent']).to eq('default_value') + end + end + + context 'with a default proc' do + let(:document) do + doc = described_class.new('key1' => 'value1', 'key2' => 'value2') + doc.default_proc = ->(_hash, key) { "default_for_#{key}" } + doc + end + + let(:hash) do + document.to_h + end + + it 'returns a Hash' do + expect(hash).to be_a(Hash) + expect(hash).not_to be_a(described_class) + end + + it 'returns a hash with the same keys and values' do + expect(hash).to eq({ 'key1' => 'value1', 'key2' => 'value2' }) + end + + it 'transfers the default proc to the resulting hash' do + expect(hash.default_proc).not_to be_nil + end + + it 'allows accessing the default with a non-existent key' do + expect(hash['non_existent']).to eq('default_for_non_existent') + end + end + + context 'with both default and default_proc set' do + let(:document) do + doc = described_class.new('key1' => 'value1') + doc.default = 'default_value' + doc.default_proc = ->(_hash, key) { "proc_for_#{key}" } + doc + end + + let(:hash) do + document.to_h + end + + it 'preserves the most recently set default behavior' do + # Ruby's behavior is to use the most recently set default mechanism + expect(hash.default).to be_nil + expect(hash.default_proc).not_to be_nil + expect(hash['non_existent']).to eq('proc_for_non_existent') + end + end end describe '#to_hash' do - let(:document) do - described_class.new('key1' => 'value1', 'key2' => { 'key3' => 'value3' }) + context 'with a single-level document' do + let(:document) do + described_class.new('key1' => 'value1', 'key2' => 'value2') + end + + let(:hash) do + document.to_hash + end + + it 'returns a Hash' do + expect(hash).to be_a(Hash) + expect(hash).not_to be_a(described_class) + end + + it 'returns a hash with the same keys and values' do + expect(hash).to eq({ 'key1' => 'value1', 'key2' => 'value2' }) + end end - let(:hash) do - document.to_hash + context 'with a nested document' do + let(:document) do + described_class.new('key1' => 'value1', 'key2' => described_class.new('key3' => 'value3')) + end + + let(:hash) do + document.to_hash + end + + it 'converts nested documents to hashes' do + nested = hash['key2'] + expect(nested).to be_a(Hash) + expect(nested).not_to be_a(described_class) + end + + it 'preserves the nested structure' do + expect(hash).to eq({ 'key1' => 'value1', 'key2' => { 'key3' => 'value3' } }) + end end - it 'is an alias for #to_h' do - expect(document.method(:to_hash)).to eq(document.method(:to_h)) + context 'with a static default' do + let(:document) do + doc = described_class.new('key1' => 'value1', 'key2' => 'value2') + doc.default = 'default_value' + doc + end + + let(:hash) do + document.to_hash + end + + it 'returns a Hash' do + expect(hash).to be_a(Hash) + expect(hash).not_to be_a(described_class) + end + + it 'returns a hash with the same keys and values' do + expect(hash).to eq({ 'key1' => 'value1', 'key2' => 'value2' }) + end + + it 'transfers the default value to the resulting hash' do + expect(hash.default).to eq('default_value') + end + + it 'allows accessing the default with a non-existent key' do + expect(hash['non_existent']).to eq('default_value') + end end + context 'with a default proc' do + let(:document) do + doc = described_class.new('key1' => 'value1', 'key2' => 'value2') + doc.default_proc = ->(_hash, key) { "default_for_#{key}" } + doc + end + + let(:hash) do + document.to_hash + end - it 'returns a Hash' do - expect(hash).to be_a(Hash) - expect(hash).not_to be_a(described_class) + it 'returns a Hash' do + expect(hash).to be_a(Hash) + expect(hash).not_to be_a(described_class) + end + + it 'returns a hash with the same keys and values' do + expect(hash).to eq({ 'key1' => 'value1', 'key2' => 'value2' }) + end + + it 'transfers the default proc to the resulting hash' do + expect(hash.default_proc).not_to be_nil + end + + it 'allows accessing the default with a non-existent key' do + expect(hash['non_existent']).to eq('default_for_non_existent') + end end - it 'converts nested documents' do - nested = hash['key2'] - expect(nested).to be_a(Hash) - expect(nested).not_to be_a(described_class) + context 'with a nested document with defaults' do + let(:nested_document) do + doc = described_class.new('inner' => 'value') + doc.default = 'inner_default' + doc + end + + let(:document) do + doc = described_class.new('key1' => 'value1', 'nested' => nested_document) + doc.default = 'outer_default' + doc + end + + let(:hash) do + document.to_hash + end + + it 'converts nested documents to hashes' do + nested = hash['nested'] + expect(nested).to be_a(Hash) + expect(nested).not_to be_a(described_class) + end + + it 'preserves the nested structure' do + expect(hash).to eq({ 'key1' => 'value1', 'nested' => { 'inner' => 'value' } }) + end + + it 'transfers the default values appropriately' do + expect(hash.default).to eq('outer_default') + expect(hash['nested'].default).to eq('inner_default') + end + + it 'allows accessing the defaults with non-existent keys' do + expect(hash['non_existent']).to eq('outer_default') + expect(hash['nested']['non_existent']).to eq('inner_default') + end end - it 'contains the correct keys and values' do - expect(hash).to eq('key1' => 'value1', 'key2' => { 'key3' => 'value3' }) + context 'with both default and default_proc set' do + let(:document) do + doc = described_class.new('key1' => 'value1') + doc.default = 'default_value' + doc.default_proc = ->(_hash, key) { "proc_for_#{key}" } + doc + end + + let(:hash) do + document.to_hash + end + + it 'preserves the most recently set default behavior' do + # Ruby's behavior is to use the most recently set default mechanism + expect(hash.default).to be_nil + expect(hash.default_proc).not_to be_nil + expect(hash['non_existent']).to eq('proc_for_non_existent') + end end end From c9702dc411efc1351c729959d0c63378d25eb73a Mon Sep 17 00:00:00 2001 From: johnnyshields <27655+johnnyshields@users.noreply.github.com> Date: Tue, 6 May 2025 02:13:17 +0900 Subject: [PATCH 4/6] Hash specs require active_support/core_ext to run --- spec/bson/document_as_spec.rb | 7 +++---- spec/spec_helper.rb | 1 + 2 files changed, 4 insertions(+), 4 deletions(-) diff --git a/spec/bson/document_as_spec.rb b/spec/bson/document_as_spec.rb index 362245c02..7d1f59bee 100644 --- a/spec/bson/document_as_spec.rb +++ b/spec/bson/document_as_spec.rb @@ -56,9 +56,8 @@ expect(result[:key1]).to eq({ 'inner' => 'value' }) end - it 'converts nested described_classs to plain Hashes' do - expect(result[:key1]).to be_a(Hash) - expect(result[:key1]).not_to be_a(described_class) + it 'does not convert nested BSON::Document to plain Hashes' do + expect(result[:key1]).to be_a(described_class) end end end @@ -310,7 +309,7 @@ expect(result['nested']['inner3']).to eq('value3') end - it 'returns nested documents as described_classs' do + it 'returns nested documents as BSON::Document' do expect(result['nested']).to be_a(described_class) end diff --git a/spec/spec_helper.rb b/spec/spec_helper.rb index 009443323..206c7990b 100644 --- a/spec/spec_helper.rb +++ b/spec/spec_helper.rb @@ -40,6 +40,7 @@ # https://github.com/rails/rails/issues/43889, etc. require 'active_support' end + require "active_support/core_ext" require "active_support/time" require 'bson/active_support' end From eb5fce41544f5b915e3a8052c89727cf8ef1efef Mon Sep 17 00:00:00 2001 From: johnnyshields <27655+johnnyshields@users.noreply.github.com> Date: Tue, 6 May 2025 02:23:22 +0900 Subject: [PATCH 5/6] Fix compact method, which doesn't work on some ruby versions apparently --- lib/bson/document.rb | 7 +++++++ 1 file changed, 7 insertions(+) diff --git a/lib/bson/document.rb b/lib/bson/document.rb index 50dcdd57b..678a27479 100644 --- a/lib/bson/document.rb +++ b/lib/bson/document.rb @@ -372,6 +372,13 @@ def to_hash end end + # Returns a new document with all nil-valued key pairs removed. + # + # @return [ BSON::Document ] A new compacted document. + def compact + dup.tap { |doc| doc.compact! } + end + # Inverts the document by using values as keys and vice versa. # # @return [ BSON::Document ] A new document with keys and values switched. From 3359b62040259f5ebad3c65bd7cf915c06897bdf Mon Sep 17 00:00:00 2001 From: johnnyshields <27655+johnnyshields@users.noreply.github.com> Date: Tue, 6 May 2025 02:26:19 +0900 Subject: [PATCH 6/6] Test ruby 3.4 for good measure --- .github/workflows/bson-ruby.yml | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/.github/workflows/bson-ruby.yml b/.github/workflows/bson-ruby.yml index ebcd288b6..cc63c1f85 100644 --- a/.github/workflows/bson-ruby.yml +++ b/.github/workflows/bson-ruby.yml @@ -20,7 +20,7 @@ jobs: fail-fast: false matrix: os: [ ubuntu, macos, windows ] - ruby: [ 2.7, 3.0, 3.1, 3.2, 3.3, head ] + ruby: [ 2.7, 3.0, 3.1, 3.2, 3.3, 3.4, head ] include: - { os: windows , ruby: mingw } exclude: