skip to Main Content

I have python fastapi app with mongodb.
Below I provided schema and code to create a document.
I need to use decimal, because there’re problems with floating point in float data type. However, there are problems with using condecimal with mongo.

class FlowerTypeSchema(BaseModel):
    id: UUID = None
    merchant_id: Optional[UUID] = None
    name: LangSchema
    description: LangSchema
    photo: Optional[str] = None
    custom: Optional[bool] = False
    rating: Optional[condecimal(ge=0)] = 0
    tags: List[str]
    consumables: List[ProductsSpentSchema]
    created_at: datetime = None
    updated_at: datetime = None
    deleted_at: datetime = None
async def create_flower_type(flower_type: FlowerTypeSchema) -> FlowerTypeResponse:
    flower_type.id = uuid4()
    flower_type.photo = None
    flower_type.created_at = datetime.now(pytz.timezone(TIMEZONE)).strftime("%Y-%m-%dT%H:%M:%S")
    flower_type.updated_at = datetime.now(pytz.timezone(TIMEZONE)).strftime("%Y-%m-%dT%H:%M:%S")
    await flower_types_collection.insert_one(flower_type.dict())
    return FlowerTypeResponse(**flower_type.dict())

I want to use decimal for rating (and for price later), but I am getting an error:

2024-06-01 14:46:32,147 - uvicorn.error - ERROR - Exception in ASGI application
Traceback (most recent call last):
  File "C:UsersAkmalAppDataLocalProgramsPythonPython310libsite-packagesuvicornprotocolshttph11_impl.py", line 408, in run_asgi
    result = await app(  # type: ignore[func-returns-value]
  File "C:UsersAkmalAppDataLocalProgramsPythonPython310libsite-packagesuvicornmiddlewareproxy_headers.py", line 84, in __call__
    return await self.app(scope, receive, send)
  File "C:UsersAkmalAppDataLocalProgramsPythonPython310libsite-packagesfastapiapplications.py", line 1106, in __call__
    await super().__call__(scope, receive, send)
  File "C:UsersAkmalAppDataLocalProgramsPythonPython310libsite-packagesstarletteapplications.py", line 122, in __call__
    await self.middleware_stack(scope, receive, send)
  File "C:UsersAkmalAppDataLocalProgramsPythonPython310libsite-packagesstarlettemiddlewareerrors.py", line 184, in __call__
    raise exc
  File "C:UsersAkmalAppDataLocalProgramsPythonPython310libsite-packagesstarlettemiddlewareerrors.py", line 162, in __call__
    await self.app(scope, receive, _send)
  File "C:UsersAkmalAppDataLocalProgramsPythonPython310libsite-packagesstarlettemiddlewarecors.py", line 83, in __call__
    await self.app(scope, receive, send)
  File "C:UsersAkmalAppDataLocalProgramsPythonPython310libsite-packagesstarlettemiddlewareexceptions.py", line 79, in __call__
    raise exc
  File "C:UsersAkmalAppDataLocalProgramsPythonPython310libsite-packagesstarlettemiddlewareexceptions.py", line 68, in __call__
    await self.app(scope, receive, sender)
  File "C:UsersAkmalAppDataLocalProgramsPythonPython310libsite-packagesfastapimiddlewareasyncexitstack.py", line 20, in __call__
    raise e
  File "C:UsersAkmalAppDataLocalProgramsPythonPython310libsite-packagesfastapimiddlewareasyncexitstack.py", line 17, in __call__
    await self.app(scope, receive, send)
  File "C:UsersAkmalAppDataLocalProgramsPythonPython310libsite-packagesstarletterouting.py", line 718, in __call__
    await route.handle(scope, receive, send)
  File "C:UsersAkmalAppDataLocalProgramsPythonPython310libsite-packagesstarletterouting.py", line 276, in handle
    await self.app(scope, receive, send)
  File "C:UsersAkmalAppDataLocalProgramsPythonPython310libsite-packagesstarletterouting.py", line 66, in app
    response = await func(request)
  File "C:UsersAkmalAppDataLocalProgramsPythonPython310libsite-packagesfastapirouting.py", line 274, in app
    raw_response = await run_endpoint_function(
  File "C:UsersAkmalAppDataLocalProgramsPythonPython310libsite-packagesfastapirouting.py", line 191, in run_endpoint_function
    return await dependant.call(**values)
  File "c:UsersAkmalITOyGulog-py-merchant-content-serviceroutesflower_types.py", line 31, in create_flower_type_data
    new_flower_type = await create_flower_type(flower_type=data)
  File "c:UsersAkmalITOyGulog-py-merchant-content-servicedatabaseflower_types.py", line 17, in create_flower_type
    await flower_types_collection.insert_one(flower_type.dict())
  File "C:UsersAkmalAppDataLocalProgramsPythonPython310libconcurrentfuturesthread.py", line 58, in run
    result = self.fn(*self.args, **self.kwargs)
  File "C:UsersAkmalAppDataLocalProgramsPythonPython310libsite-packagespymongocollection.py", line 669, in insert_one
    self._insert_one(
  File "C:UsersAkmalAppDataLocalProgramsPythonPython310libsite-packagespymongocollection.py", line 609, in _insert_one
    self.__database.client._retryable_write(acknowledged, _insert_command, session)
  File "C:UsersAkmalAppDataLocalProgramsPythonPython310libsite-packagespymongomongo_client.py", line 1523, in _retryable_write
    return self._retry_with_session(retryable, func, s, bulk)
  File "C:UsersAkmalAppDataLocalProgramsPythonPython310libsite-packagespymongomongo_client.py", line 1421, in _retry_with_session
    return self._retry_internal(
  File "C:UsersAkmalAppDataLocalProgramsPythonPython310libsite-packagespymongo_csot.py", line 107, in csot_wrapper
    return func(self, *args, **kwargs)
  File "C:UsersAkmalAppDataLocalProgramsPythonPython310libsite-packagespymongomongo_client.py", line 1462, in _retry_internal
    ).run()
  File "C:UsersAkmalAppDataLocalProgramsPythonPython310libsite-packagespymongomongo_client.py", line 2315, in run
    return self._read() if self._is_read else self._write()
  File "C:UsersAkmalAppDataLocalProgramsPythonPython310libsite-packagespymongomongo_client.py", line 2422, in _write
    return self._func(self._session, conn, self._retryable)  # type: ignore
  File "C:UsersAkmalAppDataLocalProgramsPythonPython310libsite-packagespymongocollection.py", line 597, in _insert_command
    result = conn.command(
  File "C:UsersAkmalAppDataLocalProgramsPythonPython310libsite-packagespymongohelpers.py", line 322, in inner
    return func(*args, **kwargs)
  File "C:UsersAkmalAppDataLocalProgramsPythonPython310libsite-packagespymongopool.py", line 996, in command
    self._raise_connection_failure(error)
  File "C:UsersAkmalAppDataLocalProgramsPythonPython310libsite-packagespymongopool.py", line 968, in command
    return command(
  File "C:UsersAkmalAppDataLocalProgramsPythonPython310libsite-packagespymongonetwork.py", line 151, in command
    request_id, msg, size, max_doc_size = message._op_msg(
  File "C:UsersAkmalAppDataLocalProgramsPythonPython310libsite-packagespymongomessage.py", line 762, in _op_msg
    return _op_msg_uncompressed(flags, command, identifier, docs, opts)
bson.errors.InvalidDocument: cannot encode object: Decimal('4.5'), of type: <class 'decimal.Decimal'>

3

Answers


  1. You are trying to use a Decimal field type which does not have an equivalent in JSON and BSON. So PyMongo won’t automatically convert it to something compatible. Even when using a response_model, FastAPI/Pydantic don’t have default handlers for this, unlike datetime, UUID, etc.

    Part 1: Conversion for MongoDB

    You’ll need to convert that to a string or a Decimal128 which can be used as a MongoDB type.

    Decimal128() values are 128-bit decimal-based floating-point numbers that emulate decimal rounding with exact precision.

    Do that with @field_serializer. Also make use of flower_type.model_dump() instead of flower_type.dict():

    from bson.decimal128 import Decimal128
    
    class FlowerTypeSchema(BaseModel):
        ...  # other fields
        rating: Optional[condecimal(ge=0)] = 0
        
        @field_serializer('rating', when_used='unless-none')
        def bytes_as_base64(r: Union[Decimal, condecimal]):
            return Decimal128(r)
    

    Part 2: Conversion for FastAPI response

    For the FastAPI side, since you have a FlowerTypeResponse as well as a FlowerTypeSchema, you can also use that class with inheritance and override rating to convert to string. Like in the FastAPI docs example for In/OutUser

    class FlowerTypeResponse(FlowerTypeSchema):  # inherit from FlowerTypeSchema
        rating: Optional[str] = "0"  # note the change in type
        
        # and add a validator which will convert the Decimal to string
        ...
    
    Login or Signup to reply.
  2. The problem is that pymongo can’t handle Decimal, but fastapi can’t handle bson.Decimal128. So you need different serialization methods for different contexts. I had to solve the same problem for my application that handles currency values. I did it with a custom type Money.

    ########### Custom Serialization Stuff for Decimal128 Money Type
    def validate_money(v: Any) -> Decimal:
        """
        BeforeValidator:
        Convert anything to a Decimal
        """
        match v:
            case Decimal128():
                logger.trace(f"validate_money: input is {type(v)}")
                return v.to_decimal()
            case float():
                return Decimal(str(v))
            case Decimal():
                return v
            case _:
                logger.trace(f"validate_money: input is {type(v)}")
                try:
                    return Decimal(v)
                except Exception as e:
                    raise ValueError("amount must be a valid number") from e
    
    
    def serialize_money(v: Decimal, info: SerializationInfo) -> Decimal128 | float:
        """
        PlainSerializer:
        Serialize, depending on Context
        """
        if info.context:
            output_type = info.context.get("money")
            if output_type == "Decimal128":
                logger.trace(f"serialize_money: {type(v)} to Decimal128")
                return Decimal128(str(v))
        logger.trace(f"serialize_money: {type(v)} to float")
        return float(v)
    
    
    Money = Annotated[
        Decimal,
        BeforeValidator(validate_money),
        PlainSerializer(serialize_money),
    ]
    

    Money will take a range of input values and convert them to Decimal. By default it will serialize to a float, so it’s usable in json output.

    But it offers a context switch to serialize it as bson.Decimal128:
    So in your case, when preparing a model to store into database, you would use:

    flower_type_to_mongodb = flower_type.model_dump(context={"money": Decimal128"})
    await flower_types_collection.insert_one(flower_type_to_mongodb)
    

    I’m open for suggestions on how to improve this btw, but the good thing is that it works in a reusable way.

    Login or Signup to reply.
  3. You can create your custom codec to work with Decimal. Check the link.

    It should looks like:

        from bson.decimal128 import Decimal128
        from bson.codec_options import TypeCodec, TypeRegistry, CodecOptions
        class DecimalCodec(TypeCodec):
            python_type = Decimal 
            bson_type = Decimal128
    
        def transform_python(self, value):
            return Decimal128(value)
    
        def transform_bson(self, value):
            return value.to_decimal()
    
        decimal_codec = DecimalCodec()
        type_registry = TypeRegistry([decimal_codec])
        codec_options = bson.codec_options.CodecOptions(type_registry=type_registry)
        db.get_collection("flower_types_collection", codec_options=codec_options).insert_one(flower_type.dict())
    
    Login or Signup to reply.
Please signup or login to give your own answer.
Back To Top
Search