Conversation
There was a problem hiding this comment.
Code Review
This pull request introduces zero-dependency duck-typing support for external PyMongo/bson objects (such as ObjectId, Decimal128, and Regex) in the Firestore encoder, along with corresponding unit and system tests. The review feedback correctly identifies that the duck-typing checks are too broad and could mistakenly match standard Python types like re.Pattern, potentially causing runtime errors. It is recommended to refine these checks by verifying type(value).name and to update the dummy test classes to match.
| if hasattr(value, "binary") and not isinstance(value, (bytes, bytearray, memoryview, BSONBinary)): | ||
| return encode_value(BSONObjectID(value)) | ||
| elif hasattr(value, "to_decimal") and not isinstance(value, BSONDecimal128): | ||
| return encode_value(BSONDecimal128(value)) | ||
| elif hasattr(value, "pattern") and not isinstance(value, (str, BSONRegex)): | ||
| return encode_value(BSONRegex(value)) |
There was a problem hiding this comment.
The current duck-typing checks are very broad and can lead to false positives. For example, hasattr(value, "pattern") will match standard Python compiled regular expressions (re.Pattern), which have a pattern attribute (and an integer flags attribute). Passing a re.Pattern to BSONRegex could result in runtime errors or incorrect serialization since BSON regex flags are expected to be strings, whereas re.Pattern.flags is an integer. Similarly, hasattr(value, "binary") is a common attribute name that could match other unrelated objects.
To make the zero-dependency duck-typing robust and prevent accidental interception of standard Python types or other custom classes, we should also check type(value).__name__ to ensure we only target PyMongo/bson-compatible classes (ObjectId, Decimal128, Regex).
| if hasattr(value, "binary") and not isinstance(value, (bytes, bytearray, memoryview, BSONBinary)): | |
| return encode_value(BSONObjectID(value)) | |
| elif hasattr(value, "to_decimal") and not isinstance(value, BSONDecimal128): | |
| return encode_value(BSONDecimal128(value)) | |
| elif hasattr(value, "pattern") and not isinstance(value, (str, BSONRegex)): | |
| return encode_value(BSONRegex(value)) | |
| val_type_name = type(value).__name__ | |
| if val_type_name == "ObjectId" and hasattr(value, "binary") and not isinstance(value, BSONBinary): | |
| return encode_value(BSONObjectID(value)) | |
| elif val_type_name == "Decimal128" and hasattr(value, "to_decimal") and not isinstance(value, BSONDecimal128): | |
| return encode_value(BSONDecimal128(value)) | |
| elif val_type_name == "Regex" and hasattr(value, "pattern") and not isinstance(value, BSONRegex): | |
| return encode_value(BSONRegex(value)) |
| class DummyPyMongoObjectId: | ||
| def __init__(self, raw: bytes): | ||
| self.binary = raw | ||
|
|
||
| class DummyPyMongoDecimal128: | ||
| def __init__(self, d: decimal.Decimal): | ||
| self._d = d | ||
| def to_decimal(self): | ||
| return self._d | ||
|
|
||
| class DummyPyMongoRegex: | ||
| def __init__(self, pat: str, flags: str): | ||
| self.pattern = pat | ||
| self.flags = flags | ||
|
|
||
| collection_id = "pymongo_docs_write_" + UNIQUE_RESOURCE_ID | ||
| doc_ref = client.collection(collection_id).document("pymongo_doc") | ||
| cleanup(doc_ref.delete) | ||
|
|
||
| payload = { | ||
| "_id": DummyPyMongoObjectId(bytes.fromhex("507f191e810c19729de860ea")), | ||
| "price": DummyPyMongoDecimal128(decimal.Decimal("99.99")), | ||
| "pattern": DummyPyMongoRegex("^test.*", "i"), | ||
| } |
There was a problem hiding this comment.
Rename the dummy classes to match the updated type(value).__name__ checks in _helpers.py.
class ObjectId:
def __init__(self, raw: bytes):
self.binary = raw
class Decimal128:
def __init__(self, d: decimal.Decimal):
self._d = d
def to_decimal(self):
return self._d
class Regex:
def __init__(self, pat: str, flags: str):
self.pattern = pat
self.flags = flags
collection_id = "pymongo_docs_write_" + UNIQUE_RESOURCE_ID
doc_ref = client.collection(collection_id).document("pymongo_doc")
cleanup(doc_ref.delete)
payload = {
"_id": ObjectId(bytes.fromhex("507f191e810c19729de860ea")),
"price": Decimal128(decimal.Decimal("99.99")),
"pattern": Regex("^test.*", "i"),
}| class DummyPyMongoObjectId: | ||
| def __init__(self, raw: bytes): | ||
| self.binary = raw | ||
|
|
||
| class DummyPyMongoDecimal128: | ||
| def __init__(self, d: decimal.Decimal): | ||
| self._d = d | ||
| def to_decimal(self): | ||
| return self._d | ||
|
|
||
| class DummyPyMongoRegex: | ||
| def __init__(self, pat: str, flags: str): | ||
| self.pattern = pat | ||
| self.flags = flags | ||
|
|
||
| dummy_oid = DummyPyMongoObjectId(bytes.fromhex("507f1f77bcf86cd799439011")) | ||
| res_oid = encode_value(dummy_oid) | ||
| assert res_oid.map_value.fields["__oid__"].string_value == "507f1f77bcf86cd799439011" | ||
|
|
||
| dummy_dec = DummyPyMongoDecimal128(decimal.Decimal("99.99")) | ||
| res_dec = encode_value(dummy_dec) | ||
| assert res_dec.map_value.fields["__decimal128__"].string_value == "99.99" | ||
|
|
||
| dummy_reg = DummyPyMongoRegex("^test$", "i") | ||
| res_reg = encode_value(dummy_reg) | ||
| assert res_reg.map_value.fields["__regex__"].map_value.fields["pattern"].string_value == "^test$" |
There was a problem hiding this comment.
Rename the dummy classes to match the updated type(value).__name__ checks in _helpers.py.
class ObjectId:
def __init__(self, raw: bytes):
self.binary = raw
class Decimal128:
def __init__(self, d: decimal.Decimal):
self._d = d
def to_decimal(self):
return self._d
class Regex:
def __init__(self, pat: str, flags: str):
self.pattern = pat
self.flags = flags
dummy_oid = ObjectId(bytes.fromhex("507f1f77bcf86cd799439011"))
res_oid = encode_value(dummy_oid)
assert res_oid.map_value.fields["__oid__"].string_value == "507f1f77bcf86cd799439011"
dummy_dec = Decimal128(decimal.Decimal("99.99"))
res_dec = encode_value(dummy_dec)
assert res_dec.map_value.fields["__decimal128__"].string_value == "99.99"
dummy_reg = Regex("^test$", "i")
res_reg = encode_value(dummy_reg)
assert res_reg.map_value.fields["__regex__"].map_value.fields["pattern"].string_value == "^test$"e75432a to
81bd59b
Compare
81bd59b to
10898cf
Compare
10898cf to
11d4e98
Compare
11d4e98 to
706f038
Compare
706f038 to
74bbc76
Compare
85b1cea to
eb68d2c
Compare
eb68d2c to
fa2efe2
Compare
fa2efe2 to
abaf6af
Compare
…t (PR 1B)
Thank you for opening a Pull Request! Before submitting your PR, there are a few things you can do to make sure it goes smoothly:
Fixes #<issue_number_goes_here> 🦕