Custom Type Example
This is an example of using a custom type with PyMongo. The example here shows how to subclass TypeCodec
to write a type codec, which is used to populate a TypeRegistry
. The type registry can then be used to create a custom-type-aware Collection
. Read and write operations issued against the resulting collection object transparently manipulate documents as they are saved to or retrieved from MongoDB.
Setting Up
We’ll start by getting a clean database to use for the example:
>>> from pymongo import MongoClient
>>> client = MongoClient()
>>> client.drop_database('custom_type_example')
>>> db = client.custom_type_example
Since the purpose of the example is to demonstrate working with custom types, we’ll need a custom data type to use. For this example, we will be working with the Decimal
type from Python’s standard library. Since the BSON library’s Decimal128
type (that implements the IEEE 754 decimal128 decimal-based floating-point numbering format) is distinct from Python’s built-in Decimal
type, attempting to save an instance of Decimal
with PyMongo, results in an InvalidDocument
exception.
>>> from decimal import Decimal
>>> num = Decimal("45.321")
>>> db.test.insert_one({'num': num})
Traceback (most recent call last):
...
bson.errors.InvalidDocument: cannot encode object: Decimal('45.321'), of type: <class 'decimal.Decimal'>
The TypeCodec
Class
New in version 3.8.
In order to encode a custom type, we must first define a type codec for that type. A type codec describes how an instance of a custom type can be transformed to and/or from one of the types bson
already understands. Depending on the desired functionality, users must choose from the following base classes when defining type codecs:
TypeEncoder
: subclass this to define a codec that encodes a custom Python type to a known BSON type. Users must implement thepython_type
property/attribute and thetransform_python
method.TypeDecoder
: subclass this to define a codec that decodes a specified BSON type into a custom Python type. Users must implement thebson_type
property/attribute and thetransform_bson
method.TypeCodec
: subclass this to define a codec that can both encode and decode a custom type. Users must implement thepython_type
andbson_type
properties/attributes, as well as thetransform_python
andtransform_bson
methods.
The type codec for our custom type simply needs to define how a Decimal
instance can be converted into a Decimal128
instance and vice-versa. Since we are interested in both encoding and decoding our custom type, we use the TypeCodec
base class to define our codec:
>>> from bson.decimal128 import Decimal128
>>> from bson.codec_options import TypeCodec
>>> class DecimalCodec(TypeCodec):
... python_type = Decimal # the Python type acted upon by this type codec
... bson_type = Decimal128 # the BSON type acted upon by this type codec
... def transform_python(self, value):
... """Function that transforms a custom type value into a type
... that BSON can encode."""
... return Decimal128(value)
... def transform_bson(self, value):
... """Function that transforms a vanilla BSON type value into our
... custom type."""
... return value.to_decimal()
>>> decimal_codec = DecimalCodec()
The TypeRegistry
Class
New in version 3.8.
Before we can begin encoding and decoding our custom type objects, we must first inform PyMongo about the corresponding codec. This is done by creating a TypeRegistry
instance:
>>> from bson.codec_options import TypeRegistry
>>> type_registry = TypeRegistry([decimal_codec])
Note that type registries can be instantiated with any number of type codecs. Once instantiated, registries are immutable and the only way to add codecs to a registry is to create a new one.
Putting It Together
Finally, we can define a CodecOptions
instance with our type_registry
and use it to get a Collection
object that understands the Decimal
data type:
>>> from bson.codec_options import CodecOptions
>>> codec_options = CodecOptions(type_registry=type_registry)
>>> collection = db.get_collection('test', codec_options=codec_options)
Now, we can seamlessly encode and decode instances of Decimal
:
>>> collection.insert_one({'num': Decimal("45.321")})
<pymongo.results.InsertOneResult object at ...>
>>> mydoc = collection.find_one()
>>> import pprint
>>> pprint.pprint(mydoc)
{u'_id': ObjectId('...'), u'num': Decimal('45.321')}
We can see what’s actually being saved to the database by creating a fresh collection object without the customized codec options and using that to query MongoDB:
>>> vanilla_collection = db.get_collection('test')
>>> pprint.pprint(vanilla_collection.find_one())
{u'_id': ObjectId('...'), u'num': Decimal128('45.321')}
Encoding Subtypes
Consider the situation where, in addition to encoding Decimal
, we also need to encode a type that subclasses Decimal
. PyMongo does this automatically for types that inherit from Python types that are BSON-encodable by default, but the type codec system described above does not offer the same flexibility.
Consider this subtype of Decimal
that has a method to return its value as an integer:
>>> class DecimalInt(Decimal):
... def my_method(self):
... """Method implementing some custom logic."""
... return int(self)
If we try to save an instance of this type without first registering a type codec for it, we get an error:
>>> collection.insert_one({'num': DecimalInt("45.321")})
Traceback (most recent call last):
...
bson.errors.InvalidDocument: cannot encode object: Decimal('45.321'), of type: <class 'decimal.Decimal'>
In order to proceed further, we must define a type codec for DecimalInt
. This is trivial to do since the same transformation as the one used for Decimal
is adequate for encoding DecimalInt
as well:
>>> class DecimalIntCodec(DecimalCodec):
... @property
... def python_type(self):
... """The Python type acted upon by this type codec."""
... return DecimalInt
>>> decimalint_codec = DecimalIntCodec()
Note
No attempt is made to modify decoding behavior because without additional information, it is impossible to discern which incoming Decimal128
value needs to be decoded as Decimal
and which needs to be decoded as DecimalInt
. This example only considers the situation where a user wants to encode documents containing either of these types.
After creating a new codec options object and using it to get a collection object, we can seamlessly encode instances of DecimalInt
:
>>> type_registry = TypeRegistry([decimal_codec, decimalint_codec])
>>> codec_options = CodecOptions(type_registry=type_registry)
>>> collection = db.get_collection('test', codec_options=codec_options)
>>> collection.drop()
>>> collection.insert_one({'num': DecimalInt("45.321")})
<pymongo.results.InsertOneResult object at ...>
>>> mydoc = collection.find_one()
>>> pprint.pprint(mydoc)
{u'_id': ObjectId('...'), u'num': Decimal('45.321')}
Note that the transform_bson
method of the base codec class results in these values being decoded as Decimal
(and not DecimalInt
).
Decoding Binary
Types
The decoding treatment of Binary
types having subtype = 0
by the bson
module varies slightly depending on the version of the Python runtime in use. This must be taken into account while writing a TypeDecoder
that modifies how this datatype is decoded.
On Python 3.x, Binary
data (subtype = 0
) is decoded as a bytes
instance:
>>> # On Python 3.x.
>>> from bson.binary import Binary
>>> newcoll = db.get_collection('new')
>>> newcoll.insert_one({'_id': 1, 'data': Binary(b"123", subtype=0)})
>>> doc = newcoll.find_one()
>>> type(doc['data'])
bytes
On Python 2.7.x, the same data is decoded as a Binary
instance:
>>> # On Python 2.7.x
>>> newcoll = db.get_collection('new')
>>> doc = newcoll.find_one()
>>> type(doc['data'])
bson.binary.Binary
As a consequence of this disparity, users must set the bson_type
attribute on their TypeDecoder
classes differently, depending on the python version in use.
Note
For codebases requiring compatibility with both Python 2 and 3, type decoders will have to be registered for both possible bson_type
values.
The fallback_encoder
Callable
New in version 3.8.
In addition to type codecs, users can also register a callable to encode types that BSON doesn’t recognize and for which no type codec has been registered. This callable is the fallback encoder and like the transform_python
method, it accepts an unencodable value as a parameter and returns a BSON-encodable value. The following fallback encoder encodes python’s Decimal
type to a Decimal128
:
>>> def fallback_encoder(value):
... if isinstance(value, Decimal):
... return Decimal128(value)
... return value
After declaring the callback, we must create a type registry and codec options with this fallback encoder before it can be used for initializing a collection:
>>> type_registry = TypeRegistry(fallback_encoder=fallback_encoder)
>>> codec_options = CodecOptions(type_registry=type_registry)
>>> collection = db.get_collection('test', codec_options=codec_options)
>>> collection.drop()
We can now seamlessly encode instances of Decimal
:
>>> collection.insert_one({'num': Decimal("45.321")})
<pymongo.results.InsertOneResult object at ...>
>>> mydoc = collection.find_one()
>>> pprint.pprint(mydoc)
{u'_id': ObjectId('...'), u'num': Decimal128('45.321')}
Note
Fallback encoders are invoked after attempts to encode the given value with standard BSON encoders and any configured type encoders have failed. Therefore, in a type registry configured with a type encoder and fallback encoder that both target the same custom type, the behavior specified in the type encoder will prevail.
Because fallback encoders don’t need to declare the types that they encode beforehand, they can be used to support interesting use-cases that cannot be serviced by TypeEncoder
. One such use-case is described in the next section.
Encoding Unknown Types
In this example, we demonstrate how a fallback encoder can be used to save arbitrary objects to the database. We will use the the standard library’s pickle
module to serialize the unknown types and so naturally, this approach only works for types that are picklable.
We start by defining some arbitrary custom types:
class MyStringType(object):
def __init__(self, value):
self.__value = value
def __repr__(self):
return "MyStringType('%s')" % (self.__value,)
class MyNumberType(object):
def __init__(self, value):
self.__value = value
def __repr__(self):
return "MyNumberType(%s)" % (self.__value,)
We also define a fallback encoder that pickles whatever objects it receives and returns them as Binary
instances with a custom subtype. The custom subtype, in turn, allows us to write a TypeDecoder that identifies pickled artifacts upon retrieval and transparently decodes them back into Python objects:
import pickle
from bson.binary import Binary, USER_DEFINED_SUBTYPE
def fallback_pickle_encoder(value):
return Binary(pickle.dumps(value), USER_DEFINED_SUBTYPE)
class PickledBinaryDecoder(TypeDecoder):
bson_type = Binary
def transform_bson(self, value):
if value.subtype == USER_DEFINED_SUBTYPE:
return pickle.loads(value)
return value
Note
The above example is written assuming the use of Python 3. If you are using Python 2, bson_type
must be set to Binary
. See the Decoding Binary Types section for a detailed explanation.
Finally, we create a CodecOptions
instance:
codec_options = CodecOptions(type_registry=TypeRegistry(
[PickledBinaryDecoder()], fallback_encoder=fallback_pickle_encoder))
We can now round trip our custom objects to MongoDB:
collection = db.get_collection('test_fe', codec_options=codec_options)
collection.insert_one({'_id': 1, 'str': MyStringType("hello world"),
'num': MyNumberType(2)})
mydoc = collection.find_one()
assert isinstance(mydoc['str'], MyStringType)
assert isinstance(mydoc['num'], MyNumberType)
Limitations
PyMongo’s type codec and fallback encoder features have the following limitations:
- Users cannot customize the encoding behavior of Python types that PyMongo already understands like
int
andstr
(the ‘built-in types’). Attempting to instantiate a type registry with one or more codecs that act upon a built-in type results in aTypeError
. This limitation extends to all subtypes of the standard types. - Chaining type encoders is not supported. A custom type value, once transformed by a codec’s
transform_python
method, must result in a type that is either BSON-encodable by default, or can be transformed by the fallback encoder into something BSON-encodable–it cannot be transformed a second time by a different type codec. - The
command()
method does not apply the user’s TypeDecoders while decoding the command response document. gridfs
does not apply custom type encoding or decoding to any documents received from or to returned to the user.