MongoDB Golang driver encoding issues with pointer to number type

I’m running into an issue where I register some type codecs and things almost work 100% as expected, except for one case. In the code below, the Decimal type is a pointer that stores a number. For reads, I’m using a custom decoder for the Decimal type to convert the bson Decimal128 to a Decimal and it’s working correctly. For writes, I’m using a custom encoder. It’s working except when the number value of the Decimal is 0. In the code snippet below, update1 works correctly and ServiceFee of the order is updated to the new value. However if I try to run update0 and set the value to 0, the update operation succeeds but the value of ServiceFee is not updated. With some logging enabled, I can also see that the registered encoder is NOT being called when running update0 . Since ServiceFee on update0 is of type *Decimal, I would expect the encoder to run; it shouldn’t matter that bson omitempty is applied to the ServiceFee field on OrderUpdate type because ServiceFee is NOT empty, it’s a non-nil pointer to a value. But it looks like mongo driver is seeing the *Decimal with underlying value of 0 as an empty value and omitting it.

package main

import (
	"encoding/json"
	"reflect"

	"go.mongodb.org/mongo-driver/bson"
	"go.mongodb.org/mongo-driver/bson/bsoncodec"
	"go.mongodb.org/mongo-driver/bson/bsonrw"
	"go.mongodb.org/mongo-driver/bson/bsontype"
	"go.mongodb.org/mongo-driver/bson/primitive"
	"go.mongodb.org/mongo-driver/mongo"
	"go.mongodb.org/mongo-driver/mongo/options"
	"github.com/shopspring/decimal"
)

type Decimal decimal.Decimal

type Order struct {
	Id string `bson:"_id" json:"id"`
	ServiceFee                 *Decimal           `bson:"svcFee"                     json:"serviceFee"`
}

type OrderUpdate struct {
	Id string `bson:"-" json:"id"`
	ServiceFee                 *Decimal             `bson:"svcFee,omitempty"            json:"serviceFee,omitempty"`
}

func createCustomRegistry() *bsoncodec.RegistryBuilder {
	var primitiveCodecs mongoBson.PrimitiveCodecs
	rb := bsoncodec.NewRegistryBuilder()
	bsoncodec.DefaultValueEncoders{}.RegisterDefaultEncoders(rb)
	bsoncodec.DefaultValueDecoders{}.RegisterDefaultDecoders(rb)

	var dec *Decimal
	decimalType := reflect.TypeOf(dec)

	rb.RegisterTypeDecoder(
		decimalType,
		bsoncodec.ValueDecoderFunc(func(_ bsoncodec.DecodeContext, vr bsonrw.ValueReader, val reflect.Value) error {
			if vr.Type() == bsontype.Null {
				err := vr.ReadNull()
				if err != nil {
					return err
				}
				var emptyDecimal *Decimal
				val.Set(reflect.ValueOf(emptyDecimal))
				return nil
			} else {
				read, err := vr.ReadDecimal128()
				if err != nil {
					return err
				}
				decimalValue, _ := NewDecimalFromString(read.String())
				val.Set(reflect.ValueOf(decimalValue))
				return nil
			}
		}),
	)
	rb.RegisterTypeEncoder(
		decimalType,
		bsoncodec.ValueEncoderFunc(func(_ bsoncodec.EncodeContext, vw bsonrw.ValueWriter, val reflect.Value) error {
			decimalValue := val.Interface().(*Decimal)
			nextValue, _ := primitive.ParseDecimal128(decimalValue.GetDecimalString())
			return vw.WriteDecimal128(nextValue)
		}),
	primitiveCodecs.RegisterPrimitiveCodecs(rb)
	return rb
}

func CreateClient(host, user, pwd string) (*MongoClient, error) {
	ctx, cancel := context.WithTimeout(context.Background(), 10*time.Second)
	defer cancel()
	uri := fmt.Sprintf("mongodb://%s:27017", host)
	var clientOpts *options.ClientOptions
	if user != "" {
		credentials := options.Credential{
			Username: user,
			Password: pwd,
		}
		clientOpts = options.Client().ApplyURI(uri).
			SetAuth(credentials)
	} else {
		clientOpts = options.Client().ApplyURI(uri)
	}
	registry := createCustomRegistry().Build()
	clientOpts.SetRegistry(registry)

	client, err := mongo.Connect(ctx, clientOpts)
	if err != nil {
		log.Println(err)
	}
	return client, err
}

func UpdateOrder(client  data *OrderUpdate) (order *Order, err error) {
	db := client.Database("orders_db")
	ctx := context.TODO()
	collection := db.Collection("orders")
	query := bson.M{"_id": data.Id}
	out := &Order{}

	updateQuery := bson.M{"$set": data}

	err = collection.FindOneAndUpdate(
		ctx,
		query,
		updateQuery,
		options.FindOneAndUpdate().SetReturnDocument(options.After),
	).Decode(&out)

	return out, err
}


client, _ := CreateClient("my-mongo-uri" "my-mongo-user", "my-mongo-pass")

orderId := "some-id"
serviceFee0 := 0
serviceFee1 := 1

update1 := &OrderUpdate{
	Id: orderId,
	ServiceFee: Decimal(decimal.New(int64(serviceFee1), 0))
}

// Works
result1, err1 := UpdateOrder(update1)

update0 := &OrderUpdate{
	Id: orderId,
	ServiceFee: Decimal(decimal.New(int64(serviceFee0), 0))
}

// Does not work
result0, err0 := UpdateOrder(update0)

Any suggestions are welcome, thanks!

Hey @Mark_Pare thanks for the question! I wasn’t able to reproduce the issue you described with the code snippet you posted, so I’m not sure why that’s happening.

I have some questions to help me understand your issue:

  • What version of the Go Driver are you using?
  • I noticed that the decimal.Decimal type has an IsZero method, which matches the bson.Zeroer interface. If IsZero returns true, that value will be omitted if the struct field defines omitempty in the struct tag. Does the Decimal type also have an IsZero method?

@Matt_Dale thanks for your reply. The issue was from the fact that the decimal.Decimal type has an IsZero method. I was able to get things working by overriding it.

I was a bit confused because the docs do not explicitly describe this behavior. The docs say: “structs are only considered empty if the struct type implements the bsoncodec.Zeroer interface and the IsZero method returns true”, but in my case the type is not decimal.Decimal but *decimal.Decimal. Because the type is a pointer, I’d expect a non-nil pointer to pass the omitempty bson tag filter, but it appears that for a pointer the mongo go driver checks BOTH that 1) it’s not nil and 2) if it has an IsZero method, that method returns true. This might be worth declaring explicitly in the docs.

@Mark_Pare Thanks for the reply, I’m glad you were able to get it working! You’re correct, that wording could be improved because the IsZero check applies to structs or struct pointers. It also looks like the v1.14.0 release removed the note about IsZero from the description of omitempty, which should be re-added. I’ve created GODRIVER-3138 to track that improvement.