diff --git a/.rubocop.yml b/.rubocop.yml index 48e25dba8..71ad1b1de 100644 --- a/.rubocop.yml +++ b/.rubocop.yml @@ -73,11 +73,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/lib/bson/document.rb b/lib/bson/document.rb index f46cab549..678a27479 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,148 @@ 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. + # + # @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. + # + # @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 + 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. + 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_hash.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 +496,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 @@ -343,5 +513,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_as_spec.rb b/spec/bson/document_as_spec.rb index 984c116c9..7d1f59bee 100644 --- a/spec/bson/document_as_spec.rb +++ b/spec/bson/document_as_spec.rb @@ -1,4 +1,5 @@ -# rubocop:todo all +# frozen_string_literal: true + # Copyright (C) 2021 MongoDB Inc. # # Licensed under the Apache License, Version 2.0 (the "License"); @@ -13,34 +14,999 @@ # 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 - it 'works correctly' do - doc.symbolize_keys.should == {foo: 'bar'} + let(:result) do + document.symbolize_keys + end + + it 'does not convert keys in nested documents' do + expect(result[:key1]).to eq({ 'inner' => 'value' }) + end + + it 'does not convert nested BSON::Document to plain Hashes' do + expect(result[:key1]).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).not_to 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 BSON::Document' 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..4954fef05 --- /dev/null +++ b/spec/bson/document_native_spec.rb @@ -0,0 +1,2152 @@ +# 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).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 + + 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 'does not convert nested documents to hashes' do + nested = hash['key2'] + expect(nested).to 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).not_to 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 + + 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 + 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 + + 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 + + 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) + 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 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 + + 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 + + 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.key?('key1')).to be true + end + end + + context 'with existing symbol keys' do + it 'returns true' do + expect(document.key?(:key1)).to be true + end + end + + context 'with non-existent keys' do + it 'returns false' do + expect(document.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.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.value?(:symbol_value)).to be true + end + end + + context 'with non-existent values' do + it 'returns false' do + expect(document.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(%w[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(%w[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 do + document.fetch_values('key1', 'missing') + end.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 } # rubocop:disable Lint/Void + 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 } # rubocop:disable Lint/Void + 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 } # rubocop:disable Lint/Void + expect(result).to be_nil + expect(document).to eq('key1' => 'value1', 'key2' => 'value2', '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 + + 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 } # rubocop:disable Lint/Void + 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(&: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(&: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(&: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!(&: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!(&: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(&: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(&: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(&: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 } # rubocop:disable Lint/Void + 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!(&: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(&: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 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