From c383b84a7905c825e3fbdc165f1510560733ac3a Mon Sep 17 00:00:00 2001 From: Preston Vasquez Date: Wed, 12 Nov 2025 13:07:47 -0700 Subject: [PATCH] Add DefaultDocumentMap as Decoder Method --- bson/decoder.go | 7 ++++++ bson/decoder_example_test.go | 45 ++++++++++++++++++++++++++++++++++ bson/decoder_test.go | 41 +++++++++++++++++++++++++++++++ docs/migration-2.0.md | 27 ++++++++++++++++++++ mongo/cursor.go | 7 ++++-- mongo/options/clientoptions.go | 5 ++++ 6 files changed, 130 insertions(+), 2 deletions(-) diff --git a/bson/decoder.go b/bson/decoder.go index 4c24dc6611..6e849a88e9 100644 --- a/bson/decoder.go +++ b/bson/decoder.go @@ -93,6 +93,13 @@ func (d *Decoder) DefaultDocumentM() { d.dc.defaultDocumentType = reflect.TypeOf(M{}) } +// DefaultDocumentMap causes the Decoder to always unmarshal documents into the +// map[string]any type. This behavior is restricted to data typed as "any" or +// "map[string]any". +func (d *Decoder) DefaultDocumentMap() { + d.dc.defaultDocumentType = reflect.TypeOf(map[string]any{}) +} + // AllowTruncatingDoubles causes the Decoder to truncate the fractional part of BSON "double" values // when attempting to unmarshal them into a Go integer (int, int8, int16, int32, or int64) struct // field. The truncation logic does not apply to BSON "decimal128" values. diff --git a/bson/decoder_example_test.go b/bson/decoder_example_test.go index a1db3cf867..d9b44b4739 100644 --- a/bson/decoder_example_test.go +++ b/bson/decoder_example_test.go @@ -94,6 +94,51 @@ func ExampleDecoder_DefaultDocumentM() { // Output: {"Name":"New York","Properties":{"elevation":10,"population":8804190,"state":"NY"}} } +func ExampleDecoder_DefaultDocumentMap() { + // Marshal a BSON document that contains a city name and a nested document + // with various city properties. + doc := bson.D{ + {Key: "name", Value: "New York"}, + {Key: "properties", Value: bson.D{ + {Key: "state", Value: "NY"}, + {Key: "population", Value: 8_804_190}, + {Key: "elevation", Value: 10}, + }}, + } + data, err := bson.Marshal(doc) + if err != nil { + panic(err) + } + + // Create a Decoder that reads the marshaled BSON document and use it to unmarshal the document + // into a City struct. + decoder := bson.NewDecoder(bson.NewDocumentReader(bytes.NewReader(data))) + + type City struct { + Name string `bson:"name"` + Properties any `bson:"properties"` + } + + // Configure the Decoder to default to decoding BSON documents as a + // map[string]any type if the decode destination has no type information. The + // Properties field in the City struct will be decoded as map[string]any + // instead of the default "D". + decoder.DefaultDocumentMap() + + var res City + err = decoder.Decode(&res) + if err != nil { + panic(err) + } + + data, err = json.Marshal(res) + if err != nil { + panic(err) + } + fmt.Printf("%+v\n", string(data)) + // Output: {"Name":"New York","Properties":{"elevation":10,"population":8804190,"state":"NY"}} +} + func ExampleDecoder_UseJSONStructTags() { // Marshal a BSON document that contains the name, SKU, and price (in cents) // of a product. diff --git a/bson/decoder_test.go b/bson/decoder_test.go index 68efdc6161..637718e41c 100644 --- a/bson/decoder_test.go +++ b/bson/decoder_test.go @@ -559,6 +559,23 @@ func TestDecoderConfiguration(t *testing.T) { {Key: "myDocument", Value: M{"myString": "test value"}}, }, }, + // Test that DefaultDocumentMap always decodes BSON documents into + // map[string]any values, independent of the top-level Go value type. + { + description: "DefaultDocumentMap nested", + configure: func(dec *Decoder) { + dec.DefaultDocumentMap() + }, + input: bsoncore.NewDocumentBuilder(). + AppendDocument("myDocument", bsoncore.NewDocumentBuilder(). + AppendString("myString", "test value"). + Build()). + Build(), + decodeInto: func() any { return &D{} }, + want: &D{ + {Key: "myDocument", Value: map[string]any{"myString": "test value"}}, + }, + }, // Test that ObjectIDAsHexString causes the Decoder to decode object ID to hex. { description: "ObjectIDAsHexString", @@ -697,6 +714,30 @@ func TestDecoderConfiguration(t *testing.T) { } assert.Equal(t, want, got, "expected and actual decode results do not match") }) + t.Run("DefaultDocumentMap top-level", func(t *testing.T) { + t.Parallel() + + input := bsoncore.NewDocumentBuilder(). + AppendDocument("myDocument", bsoncore.NewDocumentBuilder(). + AppendString("myString", "test value"). + Build()). + Build() + + dec := NewDecoder(NewDocumentReader(bytes.NewReader(input))) + + dec.DefaultDocumentMap() + + var got any + err := dec.Decode(&got) + require.NoError(t, err, "Decode error") + + want := map[string]any{ + "myDocument": map[string]any{ + "myString": "test value", + }, + } + assert.Equal(t, want, got, "expected and actual decode results do not match") + }) t.Run("Default decodes DocumentD for top-level", func(t *testing.T) { t.Parallel() diff --git a/docs/migration-2.0.md b/docs/migration-2.0.md index 8caae7c00e..53dae6c856 100644 --- a/docs/migration-2.0.md +++ b/docs/migration-2.0.md @@ -892,6 +892,33 @@ fmt.Printf("b3.b type: %T\n", b3["b"]) Use `Decoder.DefaultDocumentM()` or set the `DefaultDocumentM` field of `options.BSONOptions` to always decode documents into the `bson.M` type. +For full V1 compatibility, use `Decoder.DefaultDocumentMap()` instead. While +`bson.M` is defined as `type M map[string]any`, Go's type system treats `bson.M` +and `map[string]any` as distinct types. This can break compatibility with +libraries that expect actual `map[string]any` types. + +```go +b1 := map[string]any{"a": 1, "b": map[string]any{"c": 2}} +b2, _ := bson.Marshal(b1) + +decoder := bson.NewDecoder(bson.NewDocumentReader(bytes.NewReader(b2))) +decoder.DefaultDocumentMap() + +var b3 map[string]any +decoder.Decode(&b3) +fmt.Printf("b3.b type: %T\n", b3["b"]) +// Output: b3.b type: map[string]interface {} +``` + +Or configure at the client level: + +```go +clientOpts := options.Client(). + SetBSONOptions(&options.BSONOptions{ + DefaultDocumentMap: true, + }) +``` + #### NewDecoder The signature of `NewDecoder` has been updated without an error being returned. diff --git a/mongo/cursor.go b/mongo/cursor.go index bb77f4a21e..f175657046 100644 --- a/mongo/cursor.go +++ b/mongo/cursor.go @@ -320,6 +320,9 @@ func getDecoder( if opts.DefaultDocumentM { dec.DefaultDocumentM() } + if opts.DefaultDocumentMap { + dec.DefaultDocumentMap() + } if opts.ObjectIDAsHexString { dec.ObjectIDAsHexString() } @@ -423,8 +426,8 @@ func (c *Cursor) RemainingBatchLength() int { // addFromBatch adds all documents from batch to sliceVal starting at the given index. It returns the new slice value, // the next empty index in the slice, and an error if one occurs. func (c *Cursor) addFromBatch(sliceVal reflect.Value, elemType reflect.Type, batch *bsoncore.Iterator, - index int) (reflect.Value, int, error) { - + index int, +) (reflect.Value, int, error) { docs, err := batch.Documents() if err != nil { return sliceVal, index, err diff --git a/mongo/options/clientoptions.go b/mongo/options/clientoptions.go index adc880a5e9..aad5896da8 100644 --- a/mongo/options/clientoptions.go +++ b/mongo/options/clientoptions.go @@ -210,6 +210,11 @@ type BSONOptions struct { // "any" or "map[string]any". DefaultDocumentM bool + // DefaultDocumentMap causes the driver to always unmarshal documents into the + // map[string]any type. This behavior is restricted to data typed as "any" or + // "map[string]any". + DefaultDocumentMap bool + // ObjectIDAsHexString causes the Decoder to decode object IDs to their hex // representation. ObjectIDAsHexString bool