Skip to content

feat(firestore): add BSON read deserialization support - #18402

Open
ohmayr wants to merge 8 commits into
bson-pr1g-decimal128from
bson-pr2-reads
Open

ohmayr wants to merge 8 commits into
bson-pr1g-decimal128from
bson-pr2-reads

Conversation

@ohmayr

@ohmayr ohmayr commented Sep 16, 2026

Copy link
Copy Markdown
Contributor

Adds opt-in BSON read deserialization support to the Google Cloud Firestore Python SDK.
When enabled via decode_bson=True, document fields containing BSON wire map structures returned by Firestore (such as {"__oid__": "507f191e810c19729de860ea"}) are automatically deserialized into their corresponding Python BSON container instances (BSONObjectId, BSONDecimal128, BSONTimestamp, BSONRegex, BSONBinary, BSONInt32, BSONMinKey, BSONMaxKey).

💻 Usage

Default Behavior (decode_bson=False)

Existing applications continue to receive raw map dictionaries by default to preserve 100% backward compatibility:

client = firestore.Client()
doc = client.collection("users").document("doc1").get()

Opt-in Behavior (decode_bson=True)

client = firestore.Client(decode_bson=True)
doc = client.collection("users").document("doc1").get()
# Returns deserialized BSON instance: {"user_id": BSONObjectId("507f191e810c19729de860ea")}
data = doc.to_dict()

🏛️ Design Decisions

  1. Opt-In decode_bson=False Default (Enterprise Backward Safety): Defaulting to decode_bson=False ensures existing production code accessing raw dictionary keys (dict["user_id"]["oid"]) will not break upon upgrading the SDK.

  2. Subtype 0 Binary Deserialization: Wire maps representing Subtype 0 BSON Binary (v[0] == 0) are deserialized into native Python bytes (b"..."), while non-zero subtypes ($1 \le v[0] \le 255$) deserialize into BSONBinary(data, subtype=v[0]) objects.

  3. Explicit Non-None Fallback Control: Updated decode_dict() to explicitly check if decoded is not None: rather than relying on Python truthiness (or), preventing false fallback on empty byte payloads (b"") or falsy objects.

  4. Recursive Nested Map & Array Support: Added _decode_bson_dict_recursive() to ensure BSON wire maps inside nested dictionaries and array elements are deserialized properly.

Fixes b/562164140 🦕

@ohmayr
ohmayr added this pull request to stack #18386 September 16, 2026 20:30

@gemini-code-assist gemini-code-assist Bot left a comment

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Code Review

This pull request introduces several new BSON types (BSONInt32, BSONBinary, BSONTimestamp, BSONRegex, and BSONDecimal128) to the Firestore Python client, along with their respective decoders, integration tests, and unit tests. Feedback on these changes highlights a violation of Python's hash contract in BSONDecimal128 due to mixed-type equality with decimal.Decimal without matching hashes. Additionally, the reviewer recommended replacing the boolean or fallback logic in decode_dict with an explicit None check to prevent potential bugs with falsy decoded BSON objects.

Comment thread packages/google-cloud-firestore/google/cloud/firestore_v1/bson.py Outdated
Comment thread packages/google-cloud-firestore/google/cloud/firestore_v1/_helpers.py Outdated
@ohmayr
ohmayr force-pushed the bson-pr2-reads branch 3 times, most recently from 6a402d4 to 2ba70f6 Compare September 16, 2026 21:24
@ohmayr
ohmayr marked this pull request as ready for review September 16, 2026 21:24
@ohmayr
ohmayr requested a review from a team as a code owner September 16, 2026 21:24
@ohmayr
ohmayr force-pushed the bson-pr2-reads branch 3 times, most recently from 0ac138b to 38f629a Compare September 16, 2026 22:08
@ohmayr
ohmayr force-pushed the bson-pr2-reads branch 2 times, most recently from 88f98a2 to 4cdbf85 Compare September 16, 2026 23:01
Comment thread packages/google-cloud-firestore/google/cloud/firestore_v1/_helpers.py Outdated
Comment thread packages/google-cloud-firestore/google/cloud/firestore_v1/_helpers.py Outdated
Comment thread packages/google-cloud-firestore/google/cloud/firestore_v1/_helpers.py Outdated
Comment thread packages/google-cloud-firestore/google/cloud/firestore_v1/_helpers.py Outdated
Comment thread packages/google-cloud-firestore/google/cloud/firestore_v1/_helpers.py Outdated
ohmayr added a commit that referenced this pull request Sep 21, 2026
…ecode_value

- Restore full Union return type with _BSONType on decode_value.
- Restore Returns and Raises docstring sections in decode_value matching base branch.
- Remove unused _BSON_DECODERS import from _helpers.py.
- Revert extraneous changes to pipeline_result.py.

Towards #18402
ohmayr added a commit that referenced this pull request Sep 21, 2026
… types with _BSONType

- Annotate decode_dict with Union[dict, Vector, _BSONType].
- Update PipelineResult.data to return dict | Vector | _BSONType | None.
- Import _BSONType under TYPE_CHECKING in pipeline_result.py.

Towards #18402
ohmayr added a commit that referenced this pull request Sep 21, 2026
…nsions for librarian

- Make client a required positional parameter in decode_value and decode_dict.
- Format comprehensions in _helpers.py as single lines to satisfy librarian generation check.

Towards #18402

@daniel-sanche daniel-sanche left a comment

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Looking better, but a few more comments

"""Deserializes a BSON wire map dictionary into a BSON instance or bytes.

Args:
data (Any): Potential BSON wire map dictionary.

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Can this really return Any type? I would assume BSONType | bytes | None

(Try to avoid using Any wherever possible)

Copy link
Copy Markdown
Contributor Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

The decoders in only return concrete subclasses (such as , , , etc.) or if the dict doesn't match a BSON wire format. Updated the return type annotation to .

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Was this left unpushed?

Copy link
Copy Markdown
Contributor Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Yes, apologies! The commit was rebased onto bson-pr1g-decimal128 and is now pushed. BSONType._from_dict now returns Optional[Union["BSONType", bytes]].


def decode_dict(value_fields, client) -> Union[dict, Vector]:
def _decode_bson_dict_recursive(data: Any) -> Any:
"""Recursively decodes BSON wire map dictionaries."""

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

IIUC, This method shouldn't be necessary. decode_dict is already recursive, and should hanle BSON on its own. But let me know if I'm missing something

Copy link
Copy Markdown
Contributor Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

decodes from protobuf where each value is a protobuf message. In pipelines (like ), results can return already-converted python dictionaries where nested maps need BSON wire dicts converted. Having it centralized allows decoding both paths consistently.

return None
return copy.deepcopy(self._data)
data = copy.deepcopy(self._data)
return _helpers._decode_bson_dict_recursive(data)

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

This shouldn't need to change, self._data should already be in a good format (i.e., it would have run through _decode_dict before being saved to _data)

Copy link
Copy Markdown
Contributor Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Agreed! Kept untouched since it is already decoded at ingestion time.

decoder = _BSON_DECODERS.get(key)
if decoder is None:
return None
return decoder(val)

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

should we catch exceptions here, so we don't crash when reading data? Maybe fall back to None?

Copy link
Copy Markdown
Contributor Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Added a block around in so corrupted or unexpected payload shapes gracefully return rather than raising uncaught exceptions during read.

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Do you have an unpublished commit? I'm not seeing the change

Copy link
Copy Markdown
Contributor Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Pushed now! Wrapped the decoder(val) call in try...except Exception: return None so malformed or unexpected wire dictionary shapes fall back gracefully to None instead of raising unhandled exceptions during read.

Comment thread packages/google-cloud-firestore/google/cloud/firestore_v1/bson.py
def decode_dict(
value_fields,
client,
) -> Union[dict, Vector, _BSONType]:

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

bytes too?

Copy link
Copy Markdown
Contributor Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

decodes a protobuf (), which is only ever converted into a , a , or a (e.g. ). Primitive are only decoded directly by when encountering a standalone protobuf bytes field, so is not a possible return type of .

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

It looks like some words are missing from your response, but the binary field in _BSON_DECODERS can retrun bytes

(This is why I'd really like to get rid of the Any annotations. It makes it very hard to trace types)

@ohmayr ohmayr Sep 24, 2026

Copy link
Copy Markdown
Contributor Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Good catch! You're completely right. __binary__ with subtype 0 returns native bytes. Updated decode_dict and PipelineResult.data return type annotations and docstrings to include bytes.

Comment thread packages/google-cloud-firestore/google/cloud/firestore_v1/_helpers.py Outdated
ohmayr added a commit that referenced this pull request Sep 22, 2026
…nsions for librarian

- Make client a required positional parameter in decode_value and decode_dict.
- Format comprehensions in _helpers.py as single lines to satisfy librarian generation check.

Towards #18402
@ohmayr
ohmayr force-pushed the bson-pr2-reads branch 2 times, most recently from 7caf76e to 8da07c6 Compare September 22, 2026 23:22
ohmayr added a commit that referenced this pull request Sep 23, 2026
…ation

- Perform automatic BSON deserialization in decode_dict and DocumentSnapshot.to_dict using _BSONType._from_dict.
- Remove decode_bson configuration parameter across Client, AsyncClient, BaseClient, and DocumentSnapshot.
- Preserve precise return type annotations in decode_dict and restore docstring Raises section.

Towards #18402
ohmayr added a commit that referenced this pull request Sep 23, 2026
…ecode_value

- Restore full Union return type with _BSONType on decode_value.
- Restore Returns and Raises docstring sections in decode_value matching base branch.
- Remove unused _BSON_DECODERS import from _helpers.py.
- Revert extraneous changes to pipeline_result.py.

Towards #18402
ohmayr added a commit that referenced this pull request Sep 23, 2026
… types with _BSONType

- Annotate decode_dict with Union[dict, Vector, _BSONType].
- Update PipelineResult.data to return dict | Vector | _BSONType | None.
- Import _BSONType under TYPE_CHECKING in pipeline_result.py.

Towards #18402
ohmayr added a commit that referenced this pull request Sep 23, 2026
…nsions for librarian

- Make client a required positional parameter in decode_value and decode_dict.
- Format comprehensions in _helpers.py as single lines to satisfy librarian generation check.

Towards #18402
@ohmayr
ohmayr force-pushed the bson-pr2-reads branch 3 times, most recently from 6659d7b to 046ad55 Compare September 23, 2026 18:58
return hash((type(self), self._value))


_BSON_DECODERS: Dict[str, Callable[[Any], Any]] = {

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Can we get rid of the Anys here? The types should be well defined

Copy link
Copy Markdown
Contributor Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Done. Updated _BSON_DECODERS annotation to Dict[str, Callable[..., Optional[Union[BSONType, bytes]]]], eliminating all Any annotations.

ohmayr added a commit that referenced this pull request Sep 24, 2026
…ation

- Perform automatic BSON deserialization in decode_dict and DocumentSnapshot.to_dict using _BSONType._from_dict.
- Remove decode_bson configuration parameter across Client, AsyncClient, BaseClient, and DocumentSnapshot.
- Preserve precise return type annotations in decode_dict and restore docstring Raises section.

Towards #18402
ohmayr added a commit that referenced this pull request Sep 24, 2026
…ecode_value

- Restore full Union return type with _BSONType on decode_value.
- Restore Returns and Raises docstring sections in decode_value matching base branch.
- Remove unused _BSON_DECODERS import from _helpers.py.
- Revert extraneous changes to pipeline_result.py.

Towards #18402
ohmayr added a commit that referenced this pull request Sep 24, 2026
… types with _BSONType

- Annotate decode_dict with Union[dict, Vector, _BSONType].
- Update PipelineResult.data to return dict | Vector | _BSONType | None.
- Import _BSONType under TYPE_CHECKING in pipeline_result.py.

Towards #18402
ohmayr added a commit that referenced this pull request Sep 24, 2026
…nsions for librarian

- Make client a required positional parameter in decode_value and decode_dict.
- Format comprehensions in _helpers.py as single lines to satisfy librarian generation check.

Towards #18402
…ation

- Perform automatic BSON deserialization in decode_dict and DocumentSnapshot.to_dict using _BSONType._from_dict.
- Remove decode_bson configuration parameter across Client, AsyncClient, BaseClient, and DocumentSnapshot.
- Preserve precise return type annotations in decode_dict and restore docstring Raises section.

Towards #18402
…ecode_value

- Restore full Union return type with _BSONType on decode_value.
- Restore Returns and Raises docstring sections in decode_value matching base branch.
- Remove unused _BSON_DECODERS import from _helpers.py.
- Revert extraneous changes to pipeline_result.py.

Towards #18402
… types with _BSONType

- Annotate decode_dict with Union[dict, Vector, _BSONType].
- Update PipelineResult.data to return dict | Vector | _BSONType | None.
- Import _BSONType under TYPE_CHECKING in pipeline_result.py.

Towards #18402
…nsions for librarian

- Make client a required positional parameter in decode_value and decode_dict.
- Format comprehensions in _helpers.py as single lines to satisfy librarian generation check.

Towards #18402
Rename abstract base class _BSONType to BSONType and export it in
google.cloud.firestore_v1 and __all__. Update return type annotations
and docstrings on decode_value, decode_dict, and PipelineResult.data.
…back

Update BSONType._from_dict to return Optional[Union[BSONType, bytes]]
and safely catch decoder exceptions. Remove Any annotations from
_BSON_DECODERS. Update decode_dict and PipelineResult.data return types
to include bytes.
Format self.data() with !r in f-string to satisfy mypy str-bytes-safe
check after adding bytes to PipelineResult.data return type.

This branch has not been deployed

No deployments
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

2 participants