diff --git a/packages/google-cloud-firestore/google/cloud/firestore/__init__.py b/packages/google-cloud-firestore/google/cloud/firestore/__init__.py index 47eac898520d..aa5ed1a19128 100644 --- a/packages/google-cloud-firestore/google/cloud/firestore/__init__.py +++ b/packages/google-cloud-firestore/google/cloud/firestore/__init__.py @@ -35,6 +35,14 @@ AsyncQuery, AsyncTransaction, AsyncWriteBatch, + BSONBinary, + BSONDecimal128, + BSONInt32, + BSONMaxKey, + BSONMinKey, + BSONObjectID, + BSONRegex, + BSONTimestamp, Client, CollectionGroup, CollectionReference, @@ -92,6 +100,14 @@ "async_transactional", "AsyncTransaction", "AsyncWriteBatch", + "BSONBinary", + "BSONDecimal128", + "BSONInt32", + "BSONMaxKey", + "BSONMinKey", + "BSONObjectID", + "BSONRegex", + "BSONTimestamp", "Client", "CountAggregation", "CollectionGroup", diff --git a/packages/google-cloud-firestore/google/cloud/firestore_v1/__init__.py b/packages/google-cloud-firestore/google/cloud/firestore_v1/__init__.py index 38593d61832f..bfc20d47a361 100644 --- a/packages/google-cloud-firestore/google/cloud/firestore_v1/__init__.py +++ b/packages/google-cloud-firestore/google/cloud/firestore_v1/__init__.py @@ -31,6 +31,16 @@ ReadAfterWriteError, WriteOption, ) +from google.cloud.firestore_v1.bson import ( + BSONBinary, + BSONDecimal128, + BSONInt32, + BSONMaxKey, + BSONMinKey, + BSONObjectID, + BSONRegex, + BSONTimestamp, +) from google.cloud.firestore_v1.async_batch import AsyncWriteBatch from google.cloud.firestore_v1.async_client import AsyncClient from google.cloud.firestore_v1.async_collection import AsyncCollectionReference @@ -147,6 +157,14 @@ "async_transactional", "AsyncTransaction", "AsyncWriteBatch", + "BSONBinary", + "BSONDecimal128", + "BSONInt32", + "BSONMaxKey", + "BSONMinKey", + "BSONObjectID", + "BSONRegex", + "BSONTimestamp", "Client", "CountAggregation", "CollectionGroup", diff --git a/packages/google-cloud-firestore/google/cloud/firestore_v1/_helpers.py b/packages/google-cloud-firestore/google/cloud/firestore_v1/_helpers.py index 04cdd05f0c45..86ab0d1b3825 100644 --- a/packages/google-cloud-firestore/google/cloud/firestore_v1/_helpers.py +++ b/packages/google-cloud-firestore/google/cloud/firestore_v1/_helpers.py @@ -16,6 +16,7 @@ from __future__ import annotations +import collections.abc import datetime import json from typing import ( @@ -44,6 +45,16 @@ import google from google.cloud import exceptions # type: ignore from google.cloud.firestore_v1 import transforms, types +from google.cloud.firestore_v1.bson import ( + BSONBinary, + BSONDecimal128, + BSONInt32, + BSONMaxKey, + BSONMinKey, + BSONObjectID, + BSONRegex, + BSONTimestamp, +) from google.cloud.firestore_v1.field_path import FieldPath, parse_field_path from google.cloud.firestore_v1.types import common, document, write from google.cloud.firestore_v1.types.write import DocumentTransform @@ -182,6 +193,9 @@ def encode_value(value) -> types.document.Value: if value is None: return document.Value(null_value=struct_pb2.NULL_VALUE) + if hasattr(value, "to_map_value"): + return encode_value(value.to_map_value()) + # Must come before int since ``bool`` is an integer subtype. if isinstance(value, bool): return document.Value(boolean_value=value) @@ -218,9 +232,6 @@ def encode_value(value) -> types.document.Value: value_pb = document.ArrayValue(values=value_list) return document.Value(array_value=value_pb) - if isinstance(value, Vector): - return encode_value(value.to_map_value()) - if isinstance(value, dict): value_dict = encode_dict(value) value_pb = document.MapValue(fields=value_dict) @@ -344,10 +355,8 @@ def reference_value_to_document(reference_value, client) -> Any: def decode_value( - value, client -) -> Union[ - None, bool, int, float, list, datetime.datetime, str, bytes, dict, GeoPoint, Vector -]: + value, client=None, decode_bson: Optional[bool] = None +) -> Any: """Converts a Firestore protobuf ``Value`` to a native Python value. Args: @@ -355,11 +364,10 @@ def decode_value( Firestore protobuf to be decoded / parsed / converted. client (:class:`~google.cloud.firestore_v1.client.Client`): A client that has a document factory. + decode_bson (Optional[bool]): Whether to decode BSON types. Returns: - Union[NoneType, bool, int, float, datetime.datetime, \ - str, bytes, dict, ~google.cloud.Firestore.GeoPoint]: A native - Python value converted from the ``value``. + Any: A native Python value converted from the ``value``. Raises: NotImplementedError: If the ``value_type`` is ``reference_value``. @@ -390,15 +398,20 @@ def decode_value( ) elif value_type == "array_value": return [ - decode_value(element, client) for element in value_pb.array_value.values + decode_value(element, client=client, decode_bson=decode_bson) + for element in value_pb.array_value.values ] elif value_type == "map_value": - return decode_dict(value_pb.map_value.fields, client) + return decode_dict( + value_pb.map_value.fields, client=client, decode_bson=decode_bson + ) else: raise ValueError("Unknown ``value_type``", value_type) -def decode_dict(value_fields, client) -> Union[dict, Vector]: +def decode_dict( + value_fields, client=None, decode_bson: Optional[bool] = None +) -> Any: """Converts a protobuf map of Firestore ``Value``-s. Args: @@ -406,14 +419,22 @@ def decode_dict(value_fields, client) -> Union[dict, Vector]: protobuf map of Firestore ``Value``-s. client (:class:`~google.cloud.firestore_v1.client.Client`): A client that has a document factory. + decode_bson (Optional[bool]): Whether to decode BSON types. Returns: - Dict[str, Union[NoneType, bool, int, float, datetime.datetime, \ - str, bytes, dict, ~google.cloud.Firestore.GeoPoint]]: A dictionary - of native Python values converted from the ``value_fields``. + Any: A dictionary or BSON object/Vector converted from ``value_fields``. """ + effective_decode = ( + decode_bson + if decode_bson is not None + else (client.decode_bson if (client is not None and hasattr(client, "decode_bson")) else False) + ) + value_fields_pb = getattr(value_fields, "_pb", value_fields) - res = {key: decode_value(value, client) for key, value in value_fields_pb.items()} + res = { + key: decode_value(value, client=client, decode_bson=decode_bson) + for key, value in value_fields_pb.items() + } if res.get("__type__", None) == "__vector__": # Vector data type is represented as mapping. @@ -421,9 +442,48 @@ def decode_dict(value_fields, client) -> Union[dict, Vector]: values = cast(Sequence[float], res["value"]) return Vector(values) + if effective_decode and len(res) == 1: + key, val = next(iter(res.items())) + bson_obj = _parse_bson_mapping(key, val) + if bson_obj is not None: + return bson_obj + return res +def _parse_bson_mapping(key: str, val: Any) -> Optional[Any]: + """Converts legacy BSON map value representations to native BSON instances.""" + try: + if key == "__oid__" and isinstance(val, str): + return BSONObjectID(val) + elif key == "__decimal128__" and isinstance(val, str): + return BSONDecimal128(val) + elif key == "__int__" and type(val) is int: + return BSONInt32(val) + elif key == "__minkey__" and type(val) is int: + return BSONMinKey() + elif key == "__maxkey__" and type(val) is int: + return BSONMaxKey() + elif key == "__timestamp__" and isinstance(val, collections.abc.Mapping): + sec = val.get("seconds") + inc = val.get("increment") + if type(sec) is int and type(inc) is int and len(val) == 2: + return BSONTimestamp(sec, inc) + elif key == "__regex__" and isinstance(val, collections.abc.Mapping): + pat = val.get("pattern") + opt = val.get("options", "") + if isinstance(pat, str) and isinstance(opt, str) and len(val) in (1, 2): + return BSONRegex(pat, opt) + elif key == "__binary__" and isinstance(val, collections.abc.Mapping): + sub = val.get("sub_type") + bdata = val.get("bytes") + if type(sub) is int and isinstance(bdata, (bytes, bytearray, memoryview)) and len(val) == 2: + return BSONBinary(bdata, subtype=sub) + except (ValueError, TypeError): + pass + return None + + def get_doc_id(document_pb, expected_prefix) -> str: """Parse a document ID from a document protobuf. @@ -524,32 +584,29 @@ def __init__(self, document_data) -> None: prefix_path = FieldPath() iterator = self._get_document_iterator(prefix_path) + handlers = ( + (lambda v: v is transforms.DELETE_FIELD, lambda fp, v: self.deleted_fields.append(fp)), + (lambda v: v is transforms.SERVER_TIMESTAMP, lambda fp, v: self.server_timestamps.append(fp)), + (lambda v: isinstance(v, transforms.ArrayRemove), lambda fp, v: self.array_removes.update({fp: v.values})), + (lambda v: isinstance(v, transforms.ArrayUnion), lambda fp, v: self.array_unions.update({fp: v.values})), + (lambda v: isinstance(v, transforms.Increment), lambda fp, v: self.increments.update({fp: v.value})), + (lambda v: isinstance(v, transforms.Maximum), lambda fp, v: self.maximums.update({fp: v.value})), + (lambda v: isinstance(v, transforms.Minimum), lambda fp, v: self.minimums.update({fp: v.value})), + ) + for field_path, value in iterator: if field_path == prefix_path and value is _EmptyDict: self.empty_document = True + continue - elif value is transforms.DELETE_FIELD: - self.deleted_fields.append(field_path) - - elif value is transforms.SERVER_TIMESTAMP: - self.server_timestamps.append(field_path) - - elif isinstance(value, transforms.ArrayRemove): - self.array_removes[field_path] = value.values - - elif isinstance(value, transforms.ArrayUnion): - self.array_unions[field_path] = value.values - - elif isinstance(value, transforms.Increment): - self.increments[field_path] = value.value - - elif isinstance(value, transforms.Maximum): - self.maximums[field_path] = value.value - - elif isinstance(value, transforms.Minimum): - self.minimums[field_path] = value.value + handled = False + for match, action in handlers: + if match(value): + action(field_path, value) + handled = True + break - else: + if not handled: self.field_paths.append(field_path) set_field_value(self.set_fields, field_path, value) diff --git a/packages/google-cloud-firestore/google/cloud/firestore_v1/async_client.py b/packages/google-cloud-firestore/google/cloud/firestore_v1/async_client.py index 3167335e0385..6463d8a163f9 100644 --- a/packages/google-cloud-firestore/google/cloud/firestore_v1/async_client.py +++ b/packages/google-cloud-firestore/google/cloud/firestore_v1/async_client.py @@ -105,6 +105,7 @@ def __init__( database=None, client_info=_CLIENT_INFO, client_options=None, + decode_bson: bool = False, ) -> None: super(AsyncClient, self).__init__( project=project, @@ -112,6 +113,7 @@ def __init__( database=database, client_info=client_info, client_options=client_options, + decode_bson=decode_bson, ) def _to_sync_copy(self): @@ -124,6 +126,7 @@ def _to_sync_copy(self): database=self._database, client_info=self._client_info, client_options=self._client_options, + decode_bson=self.decode_bson, ) return self._sync_copy diff --git a/packages/google-cloud-firestore/google/cloud/firestore_v1/base_client.py b/packages/google-cloud-firestore/google/cloud/firestore_v1/base_client.py index 95166266bef2..9f43548841a9 100644 --- a/packages/google-cloud-firestore/google/cloud/firestore_v1/base_client.py +++ b/packages/google-cloud-firestore/google/cloud/firestore_v1/base_client.py @@ -132,6 +132,7 @@ def __init__( database=None, client_info=_CLIENT_INFO, client_options=None, + decode_bson: bool = False, ) -> None: database = database or DEFAULT_DATABASE # NOTE: This API has no use for the _http argument, but sending it @@ -165,6 +166,7 @@ def __init__( self._client_options = client_options self._database = database + self.decode_bson = decode_bson def _firestore_api_helper(self, transport, client_class, client_module) -> Any: """Lazy-loading getter GAPIC Firestore API. @@ -610,7 +612,8 @@ def _parse_batch_get( result_type = get_doc_response._pb.WhichOneof("result") if result_type == "found": reference = _get_reference(get_doc_response.found.name, reference_map) - data = _helpers.decode_dict(get_doc_response.found.fields, client) + fields = get_doc_response.found.fields + data = _helpers.decode_dict(fields, client) snapshot = DocumentSnapshot( reference, data, @@ -618,6 +621,7 @@ def _parse_batch_get( read_time=get_doc_response.read_time, create_time=get_doc_response.found.create_time, update_time=get_doc_response.found.update_time, + raw_fields=fields, ) elif result_type == "missing": reference = _get_reference(get_doc_response.missing, reference_map) diff --git a/packages/google-cloud-firestore/google/cloud/firestore_v1/base_document.py b/packages/google-cloud-firestore/google/cloud/firestore_v1/base_document.py index 92d8daa21fd6..3d0d26e3b0ad 100644 --- a/packages/google-cloud-firestore/google/cloud/firestore_v1/base_document.py +++ b/packages/google-cloud-firestore/google/cloud/firestore_v1/base_document.py @@ -388,21 +388,88 @@ class DocumentSnapshot(object): """ def __init__( - self, reference, data, exists, read_time, create_time, update_time + self, + reference, + data, + exists, + read_time, + create_time, + update_time, + raw_fields=None, + decode_bson: Optional[bool] = None, ) -> None: self._reference = reference - # We want immutable data, so callers can't modify this value - # out from under us. - self._data = copy.deepcopy(data) self._exists = exists self.read_time = read_time self.create_time = create_time self.update_time = update_time + self._raw_fields = raw_fields + + client = getattr(reference, "_client", None) if reference else None + self._decode_bson = ( + decode_bson + if decode_bson is not None + else (getattr(client, "decode_bson", False) if client else False) + ) + self._data_raw = None + self._data_bson = None + + if raw_fields is not None: + if self._decode_bson: + self._data_bson = copy.deepcopy(data) + else: + self._data_raw = copy.deepcopy(data) + else: + self._data_raw = copy.deepcopy(data) if data is not None else None + + def _get_data(self, decode_bson: Optional[bool] = None) -> Optional[Dict[str, Any]]: + effective_decode = ( + decode_bson + if decode_bson is not None + else ( + self._decode_bson + if hasattr(self, "_decode_bson") and self._decode_bson is not None + else ( + self._reference._client.decode_bson + if (self._reference and hasattr(self._reference, "_client") and self._reference._client) + else False + ) + ) + ) + + if effective_decode: + if self._data_bson is None: + if self._raw_fields is not None: + client = self._reference._client if self._reference else None + self._data_bson = _helpers.decode_dict( + self._raw_fields, client, decode_bson=True + ) + elif self._data_raw is not None: + self._data_bson = self._data_raw + return self._data_bson + else: + if self._data_raw is None: + if self._raw_fields is not None: + client = self._reference._client if self._reference else None + self._data_raw = _helpers.decode_dict( + self._raw_fields, client, decode_bson=False + ) + elif self._data_bson is not None: + self._data_raw = self._data_bson + return self._data_raw + + @property + def _data(self) -> Optional[Dict[str, Any]]: + return self._get_data() def __eq__(self, other): if not isinstance(other, self.__class__): return NotImplemented - return self._reference == other._reference and self._data == other._data + return ( + self._reference == other._reference + and self.read_time == other.read_time + and self._get_data(decode_bson=False) == other._get_data(decode_bson=False) + ) def __hash__(self): return hash(self._reference) + hash(self.update_time) @@ -448,84 +515,20 @@ def reference(self) -> BaseDocumentReference: """ return self._reference - def get(self, field_path: str) -> Any: - """Get a value from the snapshot data. - - If the data is nested, for example: - - .. code-block:: python - - >>> snapshot.to_dict() - { - 'top1': { - 'middle2': { - 'bottom3': 20, - 'bottom4': 22, - }, - 'middle5': True, - }, - 'top6': b'\x00\x01 foo', - } - - a **field path** can be used to access the nested data. For - example: - - .. code-block:: python - - >>> snapshot.get('top1') - { - 'middle2': { - 'bottom3': 20, - 'bottom4': 22, - }, - 'middle5': True, - } - >>> snapshot.get('top1.middle2') - { - 'bottom3': 20, - 'bottom4': 22, - } - >>> snapshot.get('top1.middle2.bottom3') - 20 - - See :meth:`~google.cloud.firestore_v1.client.Client.field_path` for - more information on **field paths**. - - A copy is returned since the data may contain mutable values, - but the data stored in the snapshot must remain immutable. - - Args: - field_path (str): A field path (``.``-delimited list of - field names). - - Returns: - Any or None: - (A copy of) the value stored for the ``field_path`` or - None if snapshot document does not exist. - - Raises: - KeyError: If the ``field_path`` does not match nested data - in the snapshot. - """ + def get(self, field_path: str, decode_bson: Optional[bool] = None) -> Any: + """Get a value from the snapshot data.""" if not self._exists: return None - nested_data = field_path_module.get_nested_value(field_path, self._data) + data = self._get_data(decode_bson=decode_bson) + nested_data = field_path_module.get_nested_value(field_path, data) return copy.deepcopy(nested_data) - def to_dict(self) -> Union[Dict[str, Any], None]: - """Retrieve the data contained in this snapshot. - - A copy is returned since the data may contain mutable values, - but the data stored in the snapshot must remain immutable. - - Returns: - Dict[str, Any] or None: - The data in the snapshot. Returns None if reference - does not exist. - """ + def to_dict(self, decode_bson: Optional[bool] = None) -> Union[Dict[str, Any], None]: + """Retrieve the data contained in this snapshot.""" if not self._exists: return None - return copy.deepcopy(self._data) + data = self._get_data(decode_bson=decode_bson) + return copy.deepcopy(data) def _to_protobuf(self) -> Optional[Document]: return _helpers.document_snapshot_to_protobuf(self) diff --git a/packages/google-cloud-firestore/google/cloud/firestore_v1/base_query.py b/packages/google-cloud-firestore/google/cloud/firestore_v1/base_query.py index 67a5145d2c71..ee56e31222ed 100644 --- a/packages/google-cloud-firestore/google/cloud/firestore_v1/base_query.py +++ b/packages/google-cloud-firestore/google/cloud/firestore_v1/base_query.py @@ -1496,7 +1496,8 @@ def _query_response_to_snapshot( document_id = _helpers.get_doc_id(response_pb.document, expected_prefix) reference = collection.document(document_id) - data = _helpers.decode_dict(response_pb.document.fields, collection._client) + fields = response_pb.document.fields + data = _helpers.decode_dict(fields, collection._client) snapshot = document.DocumentSnapshot( reference, data, @@ -1504,6 +1505,7 @@ def _query_response_to_snapshot( read_time=response_pb.read_time, create_time=response_pb.document.create_time, update_time=response_pb.document.update_time, + raw_fields=fields, ) return snapshot @@ -1527,7 +1529,8 @@ def _collection_group_query_response_to_snapshot( if not response_pb._pb.HasField("document"): return None reference = collection._client.document(response_pb.document.name) - data = _helpers.decode_dict(response_pb.document.fields, collection._client) + fields = response_pb.document.fields + data = _helpers.decode_dict(fields, collection._client) snapshot = document.DocumentSnapshot( reference, data, @@ -1535,6 +1538,7 @@ def _collection_group_query_response_to_snapshot( read_time=response_pb._pb.read_time, create_time=response_pb._pb.document.create_time, update_time=response_pb._pb.document.update_time, + raw_fields=fields, ) return snapshot diff --git a/packages/google-cloud-firestore/google/cloud/firestore_v1/bson.py b/packages/google-cloud-firestore/google/cloud/firestore_v1/bson.py new file mode 100644 index 000000000000..07a4d8ab1dc4 --- /dev/null +++ b/packages/google-cloud-firestore/google/cloud/firestore_v1/bson.py @@ -0,0 +1,374 @@ +# -*- coding: utf-8 -*- +# Copyright 2026 Google LLC +# +# Licensed under the Apache License, Version 2.0 (the "License"); +# you may not use this file except in compliance with the License. +# You may obtain a copy of the License at +# +# http://www.apache.org/licenses/LICENSE-2.0 +# +# Unless required by applicable law or agreed to in writing, software +# distributed under the License is distributed on an "AS IS" BASIS, +# WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +# See the License for the specific language governing permissions and +# limitations under the License. +# +"""BSON data types support for Firestore Python SDK.""" + +import binascii +import decimal +import re +from typing import Any, Dict, Tuple, Union + +__all__ = [ + "BSONObjectID", + "BSONDecimal128", + "BSONTimestamp", + "BSONRegex", + "BSONBinary", + "BSONInt32", + "BSONMinKey", + "BSONMaxKey", +] + +_HEX_24_REGEX = re.compile(r"^[0-9a-fA-F]{24}$") + + +class BSONObjectID: + """Represents a 12-byte BSON ObjectID.""" + + __slots__ = ("_value",) + + def __init__(self, value: Union[str, bytes, bytearray, memoryview, "BSONObjectID"]): + if isinstance(value, BSONObjectID): + self._value: str = value.value + elif isinstance(value, str): + if not _HEX_24_REGEX.match(value): + raise ValueError("BSONObjectID string must be a 24-character hex string.") + self._value = value.lower() + elif isinstance(value, (bytes, bytearray, memoryview)): + raw_bytes = bytes(value) + if len(raw_bytes) == 12: + self._value = binascii.hexlify(raw_bytes).decode("ascii").lower() + elif len(raw_bytes) == 24: + try: + binascii.unhexlify(raw_bytes) + except Exception as exc: + raise ValueError("BSONObjectID 24-byte input must be valid ASCII hex.") from exc + self._value = raw_bytes.decode("ascii").lower() + else: + raise ValueError("BSONObjectID bytes input must be 12 raw bytes or 24 ASCII hex bytes.") + else: + raise TypeError("BSONObjectID requires str, bytes, bytearray, memoryview, or BSONObjectID instance.") + + @property + def value(self) -> str: + return self._value + + def to_map_value(self) -> Dict[str, str]: + """Returns legacy map dictionary representation for wire serialization.""" + return {"__oid__": self._value} + + def __reduce__(self) -> Tuple[Any, Tuple[Any, ...]]: + return (self.__class__, (self._value,)) + + def __repr__(self) -> str: + return f"BSONObjectID('{self._value}')" + + def __eq__(self, other: Any) -> bool: + if isinstance(other, BSONObjectID): + return self._value == other._value + return False + + def __ne__(self, other: Any) -> bool: + return not self.__eq__(other) + + def __hash__(self) -> int: + return hash(self._value) + + +class BSONDecimal128: + """Represents a BSON IEEE 754-2008 Decimal128 value.""" + + __slots__ = ("_value",) + + def __init__(self, value: Union[str, decimal.Decimal]): + if isinstance(value, bool): + raise TypeError("BSONDecimal128 value cannot be bool.") + if isinstance(value, decimal.Decimal): + self._value: str = str(value) + elif isinstance(value, str): + try: + decimal.Decimal(value) + except (decimal.DecimalException, ArithmeticError, ValueError) as exc: + raise ValueError(f"Invalid Decimal128 string format: {value!r}") from exc + self._value = value + else: + raise TypeError("BSONDecimal128 requires str or decimal.Decimal.") + + @property + def value(self) -> str: + return self._value + + def to_decimal(self) -> decimal.Decimal: + return decimal.Decimal(self._value) + + def to_map_value(self) -> Dict[str, str]: + """Returns legacy map dictionary representation for wire serialization.""" + return {"__decimal128__": self._value} + + def __reduce__(self) -> Tuple[Any, Tuple[Any, ...]]: + return (self.__class__, (self._value,)) + + def __repr__(self) -> str: + return f"BSONDecimal128('{self._value}')" + + def __eq__(self, other: Any) -> bool: + if isinstance(other, BSONDecimal128): + try: + d1 = self.to_decimal() + d2 = other.to_decimal() + if d1.is_nan() or d2.is_nan(): + return False + return d1 == d2 + except (decimal.DecimalException, ArithmeticError): + return self._value == other._value + return False + + def __ne__(self, other: Any) -> bool: + return not self.__eq__(other) + + def __hash__(self) -> int: + try: + d = self.to_decimal() + if d.is_nan(): + return hash(self._value) + return hash(d) + except (decimal.DecimalException, ArithmeticError): + return hash(self._value) + + +class BSONTimestamp: + """Represents a BSON Timestamp (seconds + increment uint32 pair).""" + + __slots__ = ("_seconds", "_increment") + + def __init__(self, seconds: int, increment: int): + if type(seconds) is not int or type(increment) is not int: + raise TypeError("seconds and increment must be ints.") + if not (0 <= seconds <= 4294967295) or not (0 <= increment <= 4294967295): + raise ValueError("seconds and increment must be uint32 (0 to 4294967295).") + self._seconds: int = seconds + self._increment: int = increment + + @property + def seconds(self) -> int: + return self._seconds + + @property + def increment(self) -> int: + return self._increment + + def to_map_value(self) -> Dict[str, Dict[str, int]]: + """Returns legacy map dictionary representation for wire serialization.""" + return {"__timestamp__": {"seconds": self._seconds, "increment": self._increment}} + + def __reduce__(self) -> Tuple[Any, Tuple[Any, ...]]: + return (self.__class__, (self._seconds, self._increment)) + + def __repr__(self) -> str: + return f"BSONTimestamp(seconds={self._seconds}, increment={self._increment})" + + def __eq__(self, other: Any) -> bool: + if isinstance(other, BSONTimestamp): + return (self._seconds, self._increment) == (other._seconds, other._increment) + return False + + def __ne__(self, other: Any) -> bool: + return not self.__eq__(other) + + def __hash__(self) -> int: + return hash((self._seconds, self._increment)) + + +class BSONRegex: + """Represents a BSON Regular Expression.""" + + __slots__ = ("_pattern", "_options") + + def __init__(self, pattern: str, options: str = ""): + if not isinstance(pattern, str) or not isinstance(options, str): + raise TypeError("pattern and options must be strings.") + self._pattern: str = pattern + self._options: str = "".join(sorted(set(options))) + + @property + def pattern(self) -> str: + return self._pattern + + @property + def options(self) -> str: + return self._options + + def to_map_value(self) -> Dict[str, Dict[str, str]]: + """Returns legacy map dictionary representation for wire serialization.""" + return {"__regex__": {"pattern": self._pattern, "options": self._options}} + + def __reduce__(self) -> Tuple[Any, Tuple[Any, ...]]: + return (self.__class__, (self._pattern, self._options)) + + def __repr__(self) -> str: + return f"BSONRegex(pattern={self._pattern!r}, options={self._options!r})" + + def __eq__(self, other: Any) -> bool: + if isinstance(other, BSONRegex): + return (self._pattern, self._options) == (other._pattern, other._options) + return False + + def __ne__(self, other: Any) -> bool: + return not self.__eq__(other) + + def __hash__(self) -> int: + return hash((self._pattern, self._options)) + + +class BSONBinary: + """Represents BSON Binary data with a subtype.""" + + __slots__ = ("_subtype", "_data") + + def __init__(self, data: Union[bytes, bytearray, memoryview], subtype: int = 0): + if isinstance(subtype, bool) or type(subtype) is not int: + raise TypeError("subtype must be an integer.") + if not (0 <= subtype <= 255): + raise ValueError("subtype must be in range 0..255.") + try: + self._data: bytes = bytes(data) + except TypeError as exc: + raise TypeError("data must be bytes-like.") from exc + self._subtype: int = subtype + + @property + def subtype(self) -> int: + return self._subtype + + @property + def data(self) -> bytes: + return self._data + + def to_map_value(self) -> Dict[str, Dict[str, Any]]: + """Returns legacy map dictionary representation for wire serialization.""" + return {"__binary__": {"sub_type": self._subtype, "bytes": self._data}} + + def __reduce__(self) -> Tuple[Any, Tuple[Any, ...]]: + return (self.__class__, (self._data, self._subtype)) + + def __repr__(self) -> str: + return f"BSONBinary(data={self._data!r}, subtype={self._subtype})" + + def __eq__(self, other: Any) -> bool: + if isinstance(other, BSONBinary): + return (self._data, self._subtype) == (other._data, other._subtype) + return False + + def __ne__(self, other: Any) -> bool: + return not self.__eq__(other) + + def __hash__(self) -> int: + return hash((self._data, self._subtype)) + + +class BSONInt32(int): + """Represents a signed 32-bit integer BSON value.""" + + __slots__ = () + + def __new__(cls, val: Any) -> "BSONInt32": + if isinstance(val, bool): + raise TypeError("BSONInt32 value cannot be bool.") + if type(val) is not int and not isinstance(val, (int, BSONInt32)): + raise TypeError("BSONInt32 value must be an integer.") + if not (-2147483648 <= int(val) <= 2147483647): + raise ValueError("BSONInt32 out of range [-2147483648, 2147483647].") + return super().__new__(cls, val) + + def to_map_value(self) -> Dict[str, int]: + """Returns legacy map dictionary representation for wire serialization.""" + return {"__int__": int(self)} + + def __reduce__(self) -> Tuple[Any, Tuple[Any, ...]]: + return (BSONInt32, (int(self),)) + + +class BSONMinKey: + """Represents a BSON MinKey sentinel.""" + + __slots__ = () + _instance = None + + def __new__(cls) -> "BSONMinKey": + if cls._instance is None: + cls._instance = super().__new__(cls) + return cls._instance + + def to_map_value(self) -> Dict[str, int]: + """Returns legacy map dictionary representation for wire serialization.""" + return {"__minkey__": 1} + + def __reduce__(self) -> Tuple[Any, Tuple[Any, ...]]: + return (BSONMinKey, ()) + + def __copy__(self) -> "BSONMinKey": + return self + + def __deepcopy__(self, memo: Any) -> "BSONMinKey": + return self + + def __repr__(self) -> str: + return "BSONMinKey()" + + def __eq__(self, other: Any) -> bool: + return isinstance(other, BSONMinKey) + + def __ne__(self, other: Any) -> bool: + return not isinstance(other, BSONMinKey) + + def __hash__(self) -> int: + return hash("BSONMinKey") + + +class BSONMaxKey: + """Represents a BSON MaxKey sentinel.""" + + __slots__ = () + _instance = None + + def __new__(cls) -> "BSONMaxKey": + if cls._instance is None: + cls._instance = super().__new__(cls) + return cls._instance + + def to_map_value(self) -> Dict[str, int]: + """Returns legacy map dictionary representation for wire serialization.""" + return {"__maxkey__": 1} + + def __reduce__(self) -> Tuple[Any, Tuple[Any, ...]]: + return (BSONMaxKey, ()) + + def __copy__(self) -> "BSONMaxKey": + return self + + def __deepcopy__(self, memo: Any) -> "BSONMaxKey": + return self + + def __repr__(self) -> str: + return "BSONMaxKey()" + + def __eq__(self, other: Any) -> bool: + return isinstance(other, BSONMaxKey) + + def __ne__(self, other: Any) -> bool: + return not isinstance(other, BSONMaxKey) + + def __hash__(self) -> int: + return hash("BSONMaxKey") diff --git a/packages/google-cloud-firestore/google/cloud/firestore_v1/client.py b/packages/google-cloud-firestore/google/cloud/firestore_v1/client.py index e29d07cb09ac..7b97f3ac13a5 100644 --- a/packages/google-cloud-firestore/google/cloud/firestore_v1/client.py +++ b/packages/google-cloud-firestore/google/cloud/firestore_v1/client.py @@ -94,6 +94,7 @@ def __init__( database=None, client_info=_CLIENT_INFO, client_options=None, + decode_bson: bool = False, ) -> None: super(Client, self).__init__( project=project, @@ -101,6 +102,7 @@ def __init__( database=database, client_info=client_info, client_options=client_options, + decode_bson=decode_bson, ) @property diff --git a/packages/google-cloud-firestore/google/cloud/firestore_v1/order.py b/packages/google-cloud-firestore/google/cloud/firestore_v1/order.py index a3d65cc5000e..0b817f48e69c 100644 --- a/packages/google-cloud-firestore/google/cloud/firestore_v1/order.py +++ b/packages/google-cloud-firestore/google/cloud/firestore_v1/order.py @@ -1,3 +1,4 @@ +# -*- coding: utf-8 -*- # Copyright 2017 Google LLC All rights reserved. # # Licensed under the Apache License, Version 2.0 (the "License"); @@ -12,243 +13,430 @@ # See the License for the specific language governing permissions and # limitations under the License. +"""Order semantics for Firestore types matching backend database indexes.""" + +from __future__ import annotations + +import collections.abc +import datetime +import decimal import math from enum import Enum from typing import Any -from google.cloud.firestore_v1._helpers import GeoPoint, decode_value +from google.api_core.datetime_helpers import DatetimeWithNanoseconds +from google.cloud.firestore_v1._helpers import GeoPoint, decode_dict, decode_value +from google.cloud.firestore_v1.base_document import BaseDocumentReference +from google.cloud.firestore_v1.bson import ( + BSONBinary, + BSONDecimal128, + BSONInt32, + BSONMaxKey, + BSONMinKey, + BSONObjectID, + BSONRegex, + BSONTimestamp, +) +from google.cloud.firestore_v1.vector import Vector + + +class _RefValue: + """Internal wrapper for DocumentReference path comparison.""" + + def __init__(self, value: str): + self.value = value + + +def _extract_canonical_value(val: Any) -> Any: + """Extract canonical native Python representation from protobuf or BSON objects.""" + if val is None or isinstance( + val, + ( + bool, + BSONMinKey, + BSONMaxKey, + BSONObjectID, + BSONDecimal128, + BSONTimestamp, + BSONRegex, + BSONBinary, + BSONInt32, + GeoPoint, + Vector, + _RefValue, + BaseDocumentReference, + ), + ): + return val + + # Handle protobuf Value message + if hasattr(val, "_pb") or hasattr(val, "WhichOneof"): + value_pb = getattr(val, "_pb", val) + vtype = value_pb.WhichOneof("value_type") + if vtype == "null_value": + return None + elif vtype == "boolean_value": + return value_pb.boolean_value + elif vtype == "integer_value": + return value_pb.integer_value + elif vtype == "double_value": + return value_pb.double_value + elif vtype == "timestamp_value": + return DatetimeWithNanoseconds.from_timestamp_pb(value_pb.timestamp_value) + elif vtype == "string_value": + return value_pb.string_value + elif vtype == "bytes_value": + return value_pb.bytes_value + elif vtype == "reference_value": + return _RefValue(value_pb.reference_value) + elif vtype == "geo_point_value": + return GeoPoint( + value_pb.geo_point_value.latitude, value_pb.geo_point_value.longitude + ) + elif vtype == "array_value": + return [_extract_canonical_value(x) for x in value_pb.array_value.values] + elif vtype == "map_value": + decoded = decode_dict(value_pb.map_value.fields, client=None, decode_bson=True) + return _extract_canonical_value(decoded) + + # Handle legacy map dictionary with single key signature + if isinstance(val, dict) and len(val) == 1: + key = next(iter(val)) + if key in ( + "__oid__", + "__decimal128__", + "__int__", + "__minkey__", + "__maxkey__", + "__timestamp__", + "__regex__", + "__binary__", + ): + v = val[key] + if key == "__oid__" and isinstance(v, str): + return BSONObjectID(v) + elif key == "__decimal128__" and isinstance(v, str): + return BSONDecimal128(v) + elif key == "__int__" and isinstance(v, int): + return BSONInt32(v) + elif key == "__minkey__": + return BSONMinKey() + elif key == "__maxkey__": + return BSONMaxKey() + elif key == "__timestamp__" and isinstance(v, (dict, collections.abc.Mapping)): + return BSONTimestamp(v.get("seconds", 0), v.get("increment", 0)) + elif key == "__regex__" and isinstance(v, (dict, collections.abc.Mapping)): + return BSONRegex(v.get("pattern", ""), v.get("options", "")) + elif key == "__binary__" and isinstance(v, (dict, collections.abc.Mapping)): + return BSONBinary(v.get("bytes", b""), subtype=v.get("sub_type", 0)) + else: + try: + decoded = decode_dict(val, client=None, decode_bson=True) + if not isinstance(decoded, dict): + return _extract_canonical_value(decoded) + except Exception: + pass + + if isinstance(val, (list, tuple)): + return [_extract_canonical_value(x) for x in val] + + if isinstance(val, dict): + return {k: _extract_canonical_value(v) for k, v in val.items()} + + return val class TypeOrder(Enum): - """The supported Data Type. - - Note: The Enum value does not imply the sort order. - """ - - NULL = 0 - BOOLEAN = 1 - NUMBER = 2 - TIMESTAMP = 3 - STRING = 4 - BLOB = 5 - REF = 6 - GEO_POINT = 7 - ARRAY = 8 - OBJECT = 9 - VECTOR = 10 - - @staticmethod - def from_value(value) -> Any: - v = value._pb.WhichOneof("value_type") - lut = { - "null_value": TypeOrder.NULL, - "boolean_value": TypeOrder.BOOLEAN, - "integer_value": TypeOrder.NUMBER, - "double_value": TypeOrder.NUMBER, - "timestamp_value": TypeOrder.TIMESTAMP, - "string_value": TypeOrder.STRING, - "bytes_value": TypeOrder.BLOB, - "reference_value": TypeOrder.REF, - "geo_point_value": TypeOrder.GEO_POINT, - "array_value": TypeOrder.ARRAY, - "map_value": TypeOrder.OBJECT, - } - - if v not in lut: - raise ValueError(f"Could not detect value type for {v}") - - if v == "map_value": - if ( - "__type__" in value.map_value.fields - and value.map_value.fields["__type__"].string_value == "__vector__" - ): - return TypeOrder.VECTOR - return lut[v] - - -# NOTE: This order is defined by the backend and cannot be changed. + """The 17-rank BSON and Firestore data type priority order.""" + + MIN_KEY = 1 + NULL = 2 + BOOLEAN = 3 + NUMBER = 4 + TIMESTAMP = 5 + BSON_TIMESTAMP = 6 + STRING = 7 + BLOB = 8 + BSON_BINARY = 9 + REF = 10 + BSON_OBJECT_ID = 11 + GEO_POINT = 12 + BSON_REGEX = 13 + ARRAY = 14 + VECTOR = 15 + OBJECT = 16 + MAX_KEY = 17 + + @staticmethod + def from_value(value) -> TypeOrder: + cval = _extract_canonical_value(value) + if isinstance(cval, BSONMinKey): + return TypeOrder.MIN_KEY + if cval is None: + return TypeOrder.NULL + if isinstance(cval, bool): + return TypeOrder.BOOLEAN + if isinstance(cval, (int, float, BSONInt32, BSONDecimal128)): + return TypeOrder.NUMBER + if isinstance(cval, (datetime.datetime, DatetimeWithNanoseconds)): + return TypeOrder.TIMESTAMP + if isinstance(cval, BSONTimestamp): + return TypeOrder.BSON_TIMESTAMP + if isinstance(cval, str): + return TypeOrder.STRING + if isinstance(cval, bytes): + return TypeOrder.BLOB + if isinstance(cval, BSONBinary): + return TypeOrder.BSON_BINARY + if isinstance(cval, (_RefValue, BaseDocumentReference)): + return TypeOrder.REF + if isinstance(cval, BSONObjectID): + return TypeOrder.BSON_OBJECT_ID + if isinstance(cval, GeoPoint): + return TypeOrder.GEO_POINT + if isinstance(cval, BSONRegex): + return TypeOrder.BSON_REGEX + if isinstance(cval, (list, tuple)): + return TypeOrder.ARRAY + if isinstance(cval, Vector): + return TypeOrder.VECTOR + if isinstance(cval, (dict, collections.abc.Mapping)): + return TypeOrder.OBJECT + if isinstance(cval, BSONMaxKey): + return TypeOrder.MAX_KEY + + raise ValueError(f"Could not detect value type for {cval!r}") + + _TYPE_ORDER_MAP = { - TypeOrder.NULL: 0, - TypeOrder.BOOLEAN: 1, - TypeOrder.NUMBER: 2, - TypeOrder.TIMESTAMP: 3, - TypeOrder.STRING: 4, - TypeOrder.BLOB: 5, - TypeOrder.REF: 6, - TypeOrder.GEO_POINT: 7, - TypeOrder.ARRAY: 8, - TypeOrder.VECTOR: 9, - TypeOrder.OBJECT: 10, + TypeOrder.MIN_KEY: 1, + TypeOrder.NULL: 2, + TypeOrder.BOOLEAN: 3, + TypeOrder.NUMBER: 4, + TypeOrder.TIMESTAMP: 5, + TypeOrder.BSON_TIMESTAMP: 6, + TypeOrder.STRING: 7, + TypeOrder.BLOB: 8, + TypeOrder.BSON_BINARY: 9, + TypeOrder.REF: 10, + TypeOrder.BSON_OBJECT_ID: 11, + TypeOrder.GEO_POINT: 12, + TypeOrder.BSON_REGEX: 13, + TypeOrder.ARRAY: 14, + TypeOrder.VECTOR: 15, + TypeOrder.OBJECT: 16, + TypeOrder.MAX_KEY: 17, } class Order(object): - """ - Order implements the ordering semantics of the backend. - """ + """Order implements the ordering semantics of the backend.""" @classmethod def compare(cls, left, right) -> int: - """ - Main comparison function for all Firestore types. - @return -1 is left < right, 0 if left == right, otherwise 1 - """ - # First compare the types. - leftType = TypeOrder.from_value(left) - rightType = TypeOrder.from_value(right) - if leftType != rightType: - if _TYPE_ORDER_MAP[leftType] < _TYPE_ORDER_MAP[rightType]: - return -1 - else: - return 1 - - if leftType == TypeOrder.NULL: - return 0 # nulls are all equal - elif leftType == TypeOrder.BOOLEAN: - return cls._compare_to(left.boolean_value, right.boolean_value) - elif leftType == TypeOrder.NUMBER: - return cls.compare_numbers(left, right) - elif leftType == TypeOrder.TIMESTAMP: - return cls.compare_timestamps(left, right) - elif leftType == TypeOrder.STRING: - return cls._compare_to(left.string_value, right.string_value) - elif leftType == TypeOrder.BLOB: - return cls.compare_blobs(left, right) - elif leftType == TypeOrder.REF: - return cls.compare_resource_paths(left, right) - elif leftType == TypeOrder.GEO_POINT: - return cls.compare_geo_points(left, right) - elif leftType == TypeOrder.ARRAY: - return cls.compare_arrays(left, right) - elif leftType == TypeOrder.VECTOR: - # ARRAYs < VECTORs < MAPs - return cls.compare_vectors(left, right) - elif leftType == TypeOrder.OBJECT: - return cls.compare_objects(left, right) + left_canon = _extract_canonical_value(left) + right_canon = _extract_canonical_value(right) + + left_type = TypeOrder.from_value(left_canon) + right_type = TypeOrder.from_value(right_canon) + + if left_type != right_type: + left_rank = _TYPE_ORDER_MAP[left_type] + right_rank = _TYPE_ORDER_MAP[right_type] + return (left_rank > right_rank) - (left_rank < right_rank) + + if left_type in (TypeOrder.MIN_KEY, TypeOrder.NULL, TypeOrder.MAX_KEY): + return 0 + elif left_type == TypeOrder.BOOLEAN: + return cls._compare_to(left_canon, right_canon) + elif left_type == TypeOrder.NUMBER: + return cls.compare_numbers(left_canon, right_canon) + elif left_type == TypeOrder.TIMESTAMP: + return cls.compare_timestamps(left_canon, right_canon) + elif left_type == TypeOrder.BSON_TIMESTAMP: + return cls.compare_bson_timestamps(left_canon, right_canon) + elif left_type == TypeOrder.STRING: + return cls._compare_to(left_canon, right_canon) + elif left_type == TypeOrder.BLOB: + return cls._compare_to(left_canon, right_canon) + elif left_type == TypeOrder.BSON_BINARY: + return cls.compare_bson_binary(left_canon, right_canon) + elif left_type == TypeOrder.REF: + return cls.compare_resource_paths(left_canon, right_canon) + elif left_type == TypeOrder.BSON_OBJECT_ID: + return cls._compare_to(left_canon.value, right_canon.value) + elif left_type == TypeOrder.GEO_POINT: + return cls.compare_geo_points(left_canon, right_canon) + elif left_type == TypeOrder.BSON_REGEX: + return cls.compare_bson_regex(left_canon, right_canon) + elif left_type == TypeOrder.ARRAY: + return cls.compare_arrays(left_canon, right_canon) + elif left_type == TypeOrder.VECTOR: + return cls.compare_vectors(left_canon, right_canon) + elif left_type == TypeOrder.OBJECT: + return cls.compare_objects(left_canon, right_canon) else: - raise ValueError(f"Unknown TypeOrder {leftType}") + raise ValueError(f"Unknown TypeOrder {left_type}") @staticmethod - def compare_blobs(left, right) -> int: - left_bytes = left.bytes_value - right_bytes = right.bytes_value - - return Order._compare_to(left_bytes, right_bytes) + def _to_decimal_or_nan(val) -> Any: + if isinstance(val, BSONDecimal128): + try: + return val.to_decimal() + except (decimal.DecimalException, ArithmeticError): + return None + if isinstance(val, float): + if math.isnan(val): + return "NaN" + return decimal.Decimal(str(val)) + if isinstance(val, (int, BSONInt32)): + return decimal.Decimal(int(val)) + if isinstance(val, str): + try: + return decimal.Decimal(val) + except Exception: + return None + if isinstance(val, decimal.Decimal): + return val + return None @staticmethod - def compare_timestamps(left, right) -> Any: - left = left._pb.timestamp_value - right = right._pb.timestamp_value + def compare_numbers(left, right) -> int: + d_left = Order._to_decimal_or_nan(left) + d_right = Order._to_decimal_or_nan(right) + + left_is_nan = d_left == "NaN" or (isinstance(d_left, decimal.Decimal) and d_left.is_nan()) + right_is_nan = d_right == "NaN" or (isinstance(d_right, decimal.Decimal) and d_right.is_nan()) + + if left_is_nan and right_is_nan: + return 0 + if left_is_nan: + return 1 + if right_is_nan: + return -1 - seconds = Order._compare_to(left.seconds or 0, right.seconds or 0) - if seconds != 0: - return seconds + if d_left is None or d_right is None: + return 0 - return Order._compare_to(left.nanos or 0, right.nanos or 0) + if d_left == d_right: + return 0 + return 1 if d_left > d_right else -1 @staticmethod - def compare_geo_points(left, right) -> Any: - left_value = decode_value(left, None) - right_value = decode_value(right, None) - if not isinstance(left_value, GeoPoint) or not isinstance( - right_value, GeoPoint - ): - raise AttributeError("invalid geopoint encountered") - cmp = (left_value.latitude > right_value.latitude) - ( - left_value.latitude < right_value.latitude - ) + def _extract_ts_seconds_nanos(ts) -> Tuple[int, int]: + if hasattr(ts, "seconds") and hasattr(ts, "nanos"): + return (getattr(ts, "seconds", 0) or 0, getattr(ts, "nanos", 0) or 0) + if isinstance(ts, DatetimeWithNanoseconds): + ts_pb = ts.timestamp_pb() + return (ts_pb.seconds or 0, ts_pb.nanos or 0) + if isinstance(ts, datetime.datetime): + dt_seconds = int(ts.timestamp()) + dt_nanos = ts.microsecond * 1000 + return (dt_seconds, dt_nanos) + return (0, 0) - if cmp != 0: - return cmp - return (left_value.longitude > right_value.longitude) - ( - left_value.longitude < right_value.longitude - ) + @staticmethod + def compare_timestamps(left, right) -> int: + s1, n1 = Order._extract_ts_seconds_nanos(left) + s2, n2 = Order._extract_ts_seconds_nanos(right) + sec_cmp = Order._compare_to(s1, s2) + if sec_cmp != 0: + return sec_cmp + return Order._compare_to(n1, n2) + + @staticmethod + def compare_bson_timestamps(left: BSONTimestamp, right: BSONTimestamp) -> int: + sec_cmp = Order._compare_to(left.seconds, right.seconds) + if sec_cmp != 0: + return sec_cmp + return Order._compare_to(left.increment, right.increment) + + @staticmethod + def compare_bson_binary(left: BSONBinary, right: BSONBinary) -> int: + data_cmp = Order._compare_to(left.data, right.data) + if data_cmp != 0: + return data_cmp + return Order._compare_to(left.subtype, right.subtype) @staticmethod def compare_resource_paths(left, right) -> int: - left = left.reference_value - right = right.reference_value + p_left = left.path if hasattr(left, "path") else (left.value if hasattr(left, "value") else str(left)) + p_right = right.path if hasattr(right, "path") else (right.value if hasattr(right, "value") else str(right)) - left_segments = left.split("/") - right_segments = right.split("/") + left_segments = p_left.split("/") + right_segments = p_right.split("/") shorter = min(len(left_segments), len(right_segments)) - # compare segments for i in range(shorter): if left_segments[i] < right_segments[i]: return -1 if left_segments[i] > right_segments[i]: return 1 - left_length = len(left) - right_length = len(right) + left_length = len(p_left) + right_length = len(p_right) return (left_length > right_length) - (left_length < right_length) @staticmethod - def compare_arrays(left, right) -> int: - l_values = left.array_value.values - r_values = right.array_value.values + def compare_geo_points(left: GeoPoint, right: GeoPoint) -> int: + cmp = (left.latitude > right.latitude) - (left.latitude < right.latitude) + if cmp != 0: + return cmp + return (left.longitude > right.longitude) - (left.longitude < right.longitude) + + @staticmethod + def compare_bson_regex(left: BSONRegex, right: BSONRegex) -> int: + pat_cmp = Order._compare_to(left.pattern, right.pattern) + if pat_cmp != 0: + return pat_cmp + return Order._compare_to(left.options, right.options) - length = min(len(l_values), len(r_values)) + @staticmethod + def compare_arrays(left: list, right: list) -> int: + length = min(len(left), len(right)) for i in range(length): - cmp = Order.compare(l_values[i], r_values[i]) + cmp = Order.compare(left[i], right[i]) if cmp != 0: return cmp - - return Order._compare_to(len(l_values), len(r_values)) + return Order._compare_to(len(left), len(right)) @staticmethod - def compare_vectors(left, right) -> int: - # First compare the size of vector. - l_values = left.map_value.fields["value"] - r_values = right.map_value.fields["value"] - - left_length = len(l_values.array_value.values) - right_length = len(r_values.array_value.values) - - if left_length != right_length: - return Order._compare_to(left_length, right_length) - - # Compare element if the size matches. - return Order.compare_arrays(l_values, r_values) + def compare_vectors(left: Vector, right: Vector) -> int: + l_vals = list(left) if isinstance(left, (Vector, list, tuple)) else [] + r_vals = list(right) if isinstance(right, (Vector, list, tuple)) else [] + if len(l_vals) != len(r_vals): + return Order._compare_to(len(l_vals), len(r_vals)) + return Order.compare_arrays(l_vals, r_vals) @staticmethod - def compare_objects(left, right) -> int: - left_fields = left.map_value.fields - right_fields = right.map_value.fields + def compare_objects(left: dict, right: dict) -> int: + def key_sort_tuple(k): + k_canon = _extract_canonical_value(k) + k_type = TypeOrder.from_value(k_canon) + return (_TYPE_ORDER_MAP[k_type], str(k_canon)) - for left_key, right_key in zip(sorted(left_fields), sorted(right_fields)): - keyCompare = Order._compare_to(left_key, right_key) - if keyCompare != 0: - return keyCompare + left_keys = sorted(left.keys(), key=key_sort_tuple) + right_keys = sorted(right.keys(), key=key_sort_tuple) - value_compare = Order.compare( - left_fields[left_key], right_fields[right_key] - ) - if value_compare != 0: - return value_compare + for lk, rk in zip(left_keys, right_keys): + key_cmp = Order.compare(lk, rk) + if key_cmp != 0: + return key_cmp + val_cmp = Order.compare(left[lk], right[rk]) + if val_cmp != 0: + return val_cmp - return Order._compare_to(len(left_fields), len(right_fields)) + return Order._compare_to(len(left), len(right)) @staticmethod - def compare_numbers(left, right) -> int: - left_value = decode_value(left, None) - right_value = decode_value(right, None) - return Order.compare_doubles(left_value, right_value) + def compare_blobs(left, right) -> int: + left_bytes = getattr(left, "bytes_value", left) + right_bytes = getattr(right, "bytes_value", right) + return Order._compare_to(left_bytes, right_bytes) @staticmethod def compare_doubles(left, right) -> int: - if math.isnan(left): - if math.isnan(right): - return 0 - return -1 - if math.isnan(right): - return 1 - - return Order._compare_to(left, right) + return Order.compare_numbers(left, right) @staticmethod def _compare_to(left, right) -> int: - # We can't just use cmp(left, right) because cmp doesn't exist - # in Python 3, so this is an equivalent suggested by - # https://docs.python.org/3.0/whatsnew/3.0.html#ordering-comparisons return (left > right) - (left < right) diff --git a/packages/google-cloud-firestore/google/cloud/firestore_v1/transforms.py b/packages/google-cloud-firestore/google/cloud/firestore_v1/transforms.py index 5ec15b3dc2d3..30ec2231d189 100644 --- a/packages/google-cloud-firestore/google/cloud/firestore_v1/transforms.py +++ b/packages/google-cloud-firestore/google/cloud/firestore_v1/transforms.py @@ -106,6 +106,18 @@ class _NumericValue(object): """ def __init__(self, value) -> None: + from google.cloud.firestore_v1.bson import ( + BSONBinary, + BSONDecimal128, + BSONInt32, + BSONMaxKey, + BSONMinKey, + BSONObjectID, + BSONRegex, + BSONTimestamp, + ) + if isinstance(value, (BSONObjectID, BSONDecimal128, BSONTimestamp, BSONRegex, BSONBinary, BSONInt32, BSONMinKey, BSONMaxKey)): + raise TypeError("Numeric transforms (Increment, Maximum, Minimum) do not support BSON types.") if not isinstance(value, (int, float)): raise ValueError("Pass an integer / float value.") diff --git a/packages/google-cloud-firestore/tests/system/test__helpers.py b/packages/google-cloud-firestore/tests/system/test__helpers.py index 018eea55bfc1..3334b446f66e 100644 --- a/packages/google-cloud-firestore/tests/system/test__helpers.py +++ b/packages/google-cloud-firestore/tests/system/test__helpers.py @@ -20,7 +20,7 @@ EMULATOR_CREDS = EmulatorCreds() FIRESTORE_EMULATOR = os.environ.get(_FIRESTORE_EMULATOR_HOST) is not None FIRESTORE_OTHER_DB = os.environ.get("SYSTEM_TESTS_DATABASE", "system-tests-named-db") -FIRESTORE_ENTERPRISE_DB = os.environ.get("ENTERPRISE_DATABASE", "enterprise-db-native") +FIRESTORE_ENTERPRISE_DB = os.environ.get("ENTERPRISE_DATABASE", "enterprise-db-native-2") # To eliminate test duplication, we use the default database for the # core test suites. The named database is ONLY tested explicitly in dedicated diff --git a/packages/google-cloud-firestore/tests/system/test_bson.py b/packages/google-cloud-firestore/tests/system/test_bson.py new file mode 100644 index 000000000000..f5b825ae7def --- /dev/null +++ b/packages/google-cloud-firestore/tests/system/test_bson.py @@ -0,0 +1,43 @@ +# -*- coding: utf-8 -*- +# Copyright 2026 Google LLC +# +# Licensed under the Apache License, Version 2.0 (the "License"); +# you may not use this file except in compliance with the License. +# You may obtain a copy of the License at +# +# http://www.apache.org/licenses/LICENSE-2.0 +# +# Unless required by applicable law or agreed to in writing, software +# distributed under the License is distributed on an "AS IS" BASIS, +# WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +# See the License for the specific language governing permissions and +# limitations under the License. + +import pytest +from google.cloud import firestore +from google.cloud.firestore import ( + ArrayUnion, + BSONBinary, + BSONDecimal128, + BSONInt32, + BSONMaxKey, + BSONMinKey, + BSONObjectID, + BSONRegex, + BSONTimestamp, + Increment, +) + + +def test_bson_system_transforms_rejection(): + # ArrayUnion preserves BSON wrappers + union = ArrayUnion([BSONInt32(5)]) + assert union.values[0] == BSONInt32(5) + + # Increment explicitly rejects BSONInt32 with TypeError + with pytest.raises(TypeError): + Increment(BSONInt32(1)) + + # Increment explicitly rejects BSONDecimal128 with TypeError + with pytest.raises(TypeError): + Increment(BSONDecimal128("1.0")) diff --git a/packages/google-cloud-firestore/tests/unit/v1/test__helpers.py b/packages/google-cloud-firestore/tests/unit/v1/test__helpers.py index 4ce48424d3c4..968921615cbf 100644 --- a/packages/google-cloud-firestore/tests/unit/v1/test__helpers.py +++ b/packages/google-cloud-firestore/tests/unit/v1/test__helpers.py @@ -296,6 +296,64 @@ def test_encode_value_w_bad_type(): encode_value(value) +def test_encode_value_bson_types(): + from google.cloud.firestore_v1._helpers import encode_value + from google.cloud.firestore_v1.bson import ( + BSONBinary, + BSONDecimal128, + BSONInt32, + BSONMaxKey, + BSONMinKey, + BSONObjectID, + BSONRegex, + BSONTimestamp, + ) + + oid = BSONObjectID("507f1f77bcf86cd799439011") + res_oid = encode_value(oid) + assert res_oid.map_value.fields["__oid__"].string_value == "507f1f77bcf86cd799439011" + + dec = BSONDecimal128("12.34") + res_dec = encode_value(dec) + assert res_dec.map_value.fields["__decimal128__"].string_value == "12.34" + + int32 = BSONInt32(42) + res_int32 = encode_value(int32) + assert res_int32.map_value.fields["__int__"].integer_value == 42 + + ts = BSONTimestamp(100, 200) + res_ts = encode_value(ts) + assert res_ts.map_value.fields["__timestamp__"].map_value.fields["seconds"].integer_value == 100 + assert res_ts.map_value.fields["__timestamp__"].map_value.fields["increment"].integer_value == 200 + + reg = BSONRegex("pat", "i") + res_reg = encode_value(reg) + assert res_reg.map_value.fields["__regex__"].map_value.fields["pattern"].string_value == "pat" + + bin_val = BSONBinary(b"data", subtype=3) + res_bin = encode_value(bin_val) + assert res_bin.map_value.fields["__binary__"].map_value.fields["sub_type"].integer_value == 3 + assert res_bin.map_value.fields["__binary__"].map_value.fields["bytes"].bytes_value == b"data" + + min_val = BSONMinKey() + res_min = encode_value(min_val) + assert res_min.map_value.fields["__minkey__"].integer_value == 1 + + max_val = BSONMaxKey() + res_max = encode_value(max_val) + assert res_max.map_value.fields["__maxkey__"].integer_value == 1 + + +def test_decode_dict_malformed_bson_fallback(): + from google.cloud.firestore_v1._helpers import decode_dict + from google.cloud.firestore_v1.types import document + + # Invalid payload schema gracefully falls back to returning raw dict + malformed = {"__oid__": document.Value(integer_value=12345)} # should be string_value + decoded = decode_dict(malformed, client=None, decode_bson=True) + assert decoded == {"__oid__": 12345} + + def test_encode_dict_w_many_types(): from google.protobuf import struct_pb2, timestamp_pb2 diff --git a/packages/google-cloud-firestore/tests/unit/v1/test_async_bson.py b/packages/google-cloud-firestore/tests/unit/v1/test_async_bson.py new file mode 100644 index 000000000000..d7e76e38be3f --- /dev/null +++ b/packages/google-cloud-firestore/tests/unit/v1/test_async_bson.py @@ -0,0 +1,70 @@ +# -*- coding: utf-8 -*- +# Copyright 2026 Google LLC +# +# Licensed under the Apache License, Version 2.0 (the "License"); +# you may not use this file except in compliance with the License. +# You may obtain a copy of the License at +# +# http://www.apache.org/licenses/LICENSE-2.0 +# +# Unless required by applicable law or agreed to in writing, software +# distributed under the License is distributed on an "AS IS" BASIS, +# WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +# See the License for the specific language governing permissions and +# limitations under the License. + +import pytest +from google.cloud.firestore_v1.async_client import AsyncClient +from google.cloud.firestore_v1.bson import ( + BSONBinary, + BSONDecimal128, + BSONInt32, + BSONMaxKey, + BSONMinKey, + BSONObjectID, + BSONRegex, + BSONTimestamp, +) +from google.cloud.firestore_v1._helpers import encode_dict, decode_dict + + +def test_async_client_decode_bson_flag(): + client_default = AsyncClient(project="project", decode_bson=False) + assert client_default.decode_bson is False + assert client_default._to_sync_copy().decode_bson is False + + client_enabled = AsyncClient(project="project", decode_bson=True) + assert client_enabled.decode_bson is True + assert client_enabled._to_sync_copy().decode_bson is True + + +def test_async_bson_encode_decode_roundtrip(): + data = { + "oid": BSONObjectID("507f1f77bcf86cd799439011"), + "dec": BSONDecimal128("123.456"), + "ts": BSONTimestamp(100, 200), + "reg": BSONRegex("foo", "i"), + "bin": BSONBinary(b"bar", subtype=1), + "int32": BSONInt32(42), + "min": BSONMinKey(), + "max": BSONMaxKey(), + } + + encoded = encode_dict(data) + + client_disabled = AsyncClient(project="proj", decode_bson=False) + decoded_raw = decode_dict(encoded, client=client_disabled) + assert isinstance(decoded_raw["oid"], dict) + assert decoded_raw["oid"]["__oid__"] == "507f1f77bcf86cd799439011" + + client_enabled = AsyncClient(project="proj", decode_bson=True) + decoded_bson = decode_dict(encoded, client=client_enabled) + assert isinstance(decoded_bson["oid"], BSONObjectID) + assert decoded_bson["oid"].value == "507f1f77bcf86cd799439011" + assert isinstance(decoded_bson["dec"], BSONDecimal128) + assert isinstance(decoded_bson["ts"], BSONTimestamp) + assert isinstance(decoded_bson["reg"], BSONRegex) + assert isinstance(decoded_bson["bin"], BSONBinary) + assert isinstance(decoded_bson["int32"], BSONInt32) + assert isinstance(decoded_bson["min"], BSONMinKey) + assert isinstance(decoded_bson["max"], BSONMaxKey) diff --git a/packages/google-cloud-firestore/tests/unit/v1/test_bson.py b/packages/google-cloud-firestore/tests/unit/v1/test_bson.py new file mode 100644 index 000000000000..eab708f0eae6 --- /dev/null +++ b/packages/google-cloud-firestore/tests/unit/v1/test_bson.py @@ -0,0 +1,199 @@ +# -*- coding: utf-8 -*- +# Copyright 2026 Google LLC +# +# Licensed under the Apache License, Version 2.0 (the "License"); +# you may not use this file except in compliance with the License. +# You may obtain a copy of the License at +# +# http://www.apache.org/licenses/LICENSE-2.0 +# +# Unless required by applicable law or agreed to in writing, software +# distributed under the License is distributed on an "AS IS" BASIS, +# WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +# See the License for the specific language governing permissions and +# limitations under the License. +# +"""Unit tests for google.cloud.firestore_v1.bson classes.""" + +import copy +import decimal +import pickle +import pytest + +from google.cloud.firestore_v1.bson import ( + BSONBinary, + BSONDecimal128, + BSONInt32, + BSONMaxKey, + BSONMinKey, + BSONObjectID, + BSONRegex, + BSONTimestamp, +) + + +def test_bson_object_id_constructors_and_properties(): + # 24-char hex string + hex_str = "507f191e810c19729de860ea" + oid1 = BSONObjectID(hex_str) + assert oid1.value == hex_str + assert oid1.to_map_value() == {"__oid__": hex_str} + + # 12 raw bytes + raw_bytes = b"\x50\x7f\x19\x1e\x81\x0c\x19\x72\x9d\xe8\x60\xea" + oid2 = BSONObjectID(raw_bytes) + assert oid2.value == hex_str + + # 24 ASCII hex bytes + ascii_bytes = b"507f191e810c19729de860ea" + oid3 = BSONObjectID(ascii_bytes) + assert oid3.value == hex_str + + # Copy constructor + oid4 = BSONObjectID(oid1) + assert oid4.value == hex_str + + # Invalid constructors + with pytest.raises(ValueError): + BSONObjectID("invalid_hex") + + with pytest.raises(ValueError): + BSONObjectID(b"short") + + with pytest.raises(TypeError): + BSONObjectID(12345) + + +def test_bson_object_id_equality_hash_pickle(): + oid1 = BSONObjectID("507f191e810c19729de860ea") + oid2 = BSONObjectID("507f191e810c19729de860ea") + oid3 = BSONObjectID("000000000000000000000000") + + assert oid1 == oid2 + assert oid1 != oid3 + assert hash(oid1) == hash(oid2) + + # Pickle + pickled = pickle.dumps(oid1) + unpickled = pickle.loads(pickled) + assert unpickled == oid1 + assert unpickled.value == oid1.value + + +def test_bson_decimal128(): + dec1 = BSONDecimal128("0.10") + assert dec1.value == "0.10" + assert dec1.to_decimal() == decimal.Decimal("0.10") + assert dec1.to_map_value() == {"__decimal128__": "0.10"} + + dec_nan = BSONDecimal128("NaN") + assert dec_nan.to_decimal().is_nan() + + # Eager validation + with pytest.raises(ValueError): + BSONDecimal128("not_a_number") + + with pytest.raises(TypeError): + BSONDecimal128(True) + + # Pickle + pickled = pickle.dumps(dec1) + unpickled = pickle.loads(pickled) + assert unpickled == dec1 + + +def test_bson_timestamp(): + ts1 = BSONTimestamp(1600000000, 1) + assert ts1.seconds == 1600000000 + assert ts1.increment == 1 + assert ts1.to_map_value() == {"__timestamp__": {"seconds": 1600000000, "increment": 1}} + + # Bounds validation + with pytest.raises(ValueError): + BSONTimestamp(4294967296, 1) + + with pytest.raises(TypeError): + BSONTimestamp(100.5, 1) + + # Pickle + pickled = pickle.dumps(ts1) + unpickled = pickle.loads(pickled) + assert unpickled == ts1 + + +def test_bson_regex(): + reg = BSONRegex("abc", "ixm") + assert reg.pattern == "abc" + assert reg.options == "imx" + assert reg.to_map_value() == {"__regex__": {"pattern": "abc", "options": "imx"}} + + # Pickle + pickled = pickle.dumps(reg) + unpickled = pickle.loads(pickled) + assert unpickled == reg + + +def test_bson_binary(): + bin1 = BSONBinary(b"data", 0) + assert bin1.data == b"data" + assert bin1.subtype == 0 + assert bin1.to_map_value() == {"__binary__": {"sub_type": 0, "bytes": b"data"}} + + # Default subtype=0 + bin_default = BSONBinary(b"data") + assert bin_default.subtype == 0 + + # Pickle + pickled = pickle.dumps(bin1) + unpickled = pickle.loads(pickled) + assert unpickled == bin1 + assert unpickled.data == b"data" + assert unpickled.subtype == 0 + + +def test_bson_int32(): + val = BSONInt32(100) + assert isinstance(val, BSONInt32) + assert isinstance(val, int) + assert val == 100 + assert val.to_map_value() == {"__int__": 100} + + # Bounds validation + with pytest.raises(ValueError): + BSONInt32(2147483648) + + with pytest.raises(ValueError): + BSONInt32(-2147483649) + + with pytest.raises(TypeError): + BSONInt32(True) + + # Native integer arithmetic + res_add = val + 50 + assert res_add == 150 + + # Pickle + pickled = pickle.dumps(val) + unpickled = pickle.loads(pickled) + assert unpickled == val + assert isinstance(unpickled, BSONInt32) + + +def test_bson_min_key_and_max_key_singletons(): + min1 = BSONMinKey() + min2 = BSONMinKey() + assert min1 is min2 + assert min1.to_map_value() == {"__minkey__": 1} + + max1 = BSONMaxKey() + max2 = BSONMaxKey() + assert max1 is max2 + assert max1.to_map_value() == {"__maxkey__": 1} + + # Copy / Deepcopy + assert copy.copy(min1) is min1 + assert copy.deepcopy(min1) is min1 + + # Pickle + assert pickle.loads(pickle.dumps(min1)) is min1 + assert pickle.loads(pickle.dumps(max1)) is max1 diff --git a/packages/google-cloud-firestore/tests/unit/v1/test_order.py b/packages/google-cloud-firestore/tests/unit/v1/test_order.py index 1942a5298438..f05b9f395afb 100644 --- a/packages/google-cloud-firestore/tests/unit/v1/test_order.py +++ b/packages/google-cloud-firestore/tests/unit/v1/test_order.py @@ -13,127 +13,121 @@ # See the License for the specific language governing permissions and # limitations under the License. +import decimal import mock import pytest +from google.cloud.firestore_v1.bson import ( + BSONBinary, + BSONDecimal128, + BSONInt32, + BSONMaxKey, + BSONMinKey, + BSONObjectID, + BSONRegex, + BSONTimestamp, +) +from google.cloud.firestore_v1.order import Order, TypeOrder, _TYPE_ORDER_MAP -def _make_order(*args, **kwargs): - from google.cloud.firestore_v1.order import Order +def _make_order(*args, **kwargs): return Order(*args, **kwargs) def test_order_compare_across_heterogenous_values(): - from google.cloud.firestore_v1.order import Order - - # Constants used to represent min/max values of storage types. int_max_value = 2**31 - 1 int_min_value = -(2**31) float_min_value = 1.175494351**-38 float_nan = float("nan") inf = float("inf") - groups = [None] * 68 - - groups[0] = [nullValue()] - - groups[1] = [_boolean_value(False)] - groups[2] = [_boolean_value(True)] - - # numbers - groups[3] = [_double_value(float_nan), _double_value(float_nan)] - groups[4] = [_double_value(-inf)] - groups[5] = [_int_value(int_min_value - 1)] - groups[6] = [_int_value(int_min_value)] - groups[7] = [_double_value(-1.1)] - # Integers and Doubles order the same. - groups[8] = [_int_value(-1), _double_value(-1.0)] - groups[9] = [_double_value(-float_min_value)] - # zeros all compare the same. - groups[10] = [ - _int_value(0), - _double_value(-0.0), - _double_value(0.0), - _double_value(+0.0), - ] - groups[11] = [_double_value(float_min_value)] - groups[12] = [_int_value(1), _double_value(1.0)] - groups[13] = [_double_value(1.1)] - groups[14] = [_int_value(int_max_value)] - groups[15] = [_int_value(int_max_value + 1)] - groups[16] = [_double_value(inf)] - - groups[17] = [_timestamp_value(123, 0)] - groups[18] = [_timestamp_value(123, 123)] - groups[19] = [_timestamp_value(345, 0)] - - # strings - groups[20] = [_string_value("")] - groups[21] = [_string_value("\u0000\ud7ff\ue000\uffff")] - groups[22] = [_string_value("(╯°□°)╯︵ ┻━┻")] - groups[23] = [_string_value("a")] - groups[24] = [_string_value("abc def")] - # latin small letter e + combining acute accent + latin small letter b - groups[25] = [_string_value("e\u0301b")] - groups[26] = [_string_value("æ")] - # latin small letter e with acute accent + latin small letter a - groups[27] = [_string_value("\u00e9a")] - - # blobs - groups[28] = [_blob_value(b"")] - groups[29] = [_blob_value(b"\x00")] - groups[30] = [_blob_value(b"\x00\x01\x02\x03\x04")] - groups[31] = [_blob_value(b"\x00\x01\x02\x04\x03")] - groups[32] = [_blob_value(b"\x7f")] - - # resource names - groups[33] = [_reference_value("projects/p1/databases/d1/documents/c1/doc1")] - groups[34] = [_reference_value("projects/p1/databases/d1/documents/c1/doc2")] - groups[35] = [ - _reference_value("projects/p1/databases/d1/documents/c1/doc2/c2/doc1") - ] - groups[36] = [ - _reference_value("projects/p1/databases/d1/documents/c1/doc2/c2/doc2") - ] - groups[37] = [_reference_value("projects/p1/databases/d1/documents/c10/doc1")] - groups[38] = [_reference_value("projects/p1/databases/d1/documents/c2/doc1")] - groups[39] = [_reference_value("projects/p2/databases/d2/documents/c1/doc1")] - groups[40] = [_reference_value("projects/p2/databases/d2/documents/c1-/doc1")] - groups[41] = [_reference_value("projects/p2/databases/d3/documents/c1-/doc1")] - - # geo points - groups[42] = [_geoPoint_value(-90, -180)] - groups[43] = [_geoPoint_value(-90, 0)] - groups[44] = [_geoPoint_value(-90, 180)] - groups[45] = [_geoPoint_value(0, -180)] - groups[46] = [_geoPoint_value(0, 0)] - groups[47] = [_geoPoint_value(0, 180)] - groups[48] = [_geoPoint_value(1, -180)] - groups[49] = [_geoPoint_value(1, 0)] - groups[50] = [_geoPoint_value(1, 180)] - groups[51] = [_geoPoint_value(90, -180)] - groups[52] = [_geoPoint_value(90, 0)] - groups[53] = [_geoPoint_value(90, 180)] - - # arrays - groups[54] = [_array_value()] - groups[55] = [_array_value(["bar"])] - groups[56] = [_array_value(["foo"])] - groups[57] = [_array_value(["foo", 0])] - groups[58] = [_array_value(["foo", 1])] - groups[59] = [_array_value(["foo", "0"])] - - # vectors - groups[60] = [_object_value({"__type__": "__vector__", "value": [3.0, 2.0]})] - groups[61] = [_object_value({"__type__": "__vector__", "value": [1.0, 2.0, 5.0]})] - groups[62] = [_object_value({"__type__": "__vector__", "value": [2.0, 2.0, 5.0]})] - - # objects - groups[63] = [_object_value({"bar": 0})] - groups[64] = [_object_value({"bar": 0, "foo": 1})] - groups[65] = [_object_value({"bar": 1})] - groups[66] = [_object_value({"bar": 2})] - groups[67] = [_object_value({"bar": "0"})] + groups = [] + + # Rank 1: MinKey + groups.append([BSONMinKey()]) + + # Rank 2: Null + groups.append([nullValue()]) + + # Rank 3: Boolean + groups.append([_boolean_value(False)]) + groups.append([_boolean_value(True)]) + + # Rank 4: Numbers + groups.append([_double_value(-inf)]) + groups.append([_int_value(int_min_value - 1)]) + groups.append([_int_value(int_min_value), BSONInt32(int_min_value)]) + groups.append([_double_value(-1.1)]) + groups.append([_int_value(-1), _double_value(-1.0), BSONInt32(-1), BSONDecimal128("-1.0")]) + groups.append([_double_value(-float_min_value)]) + groups.append([_int_value(0), _double_value(-0.0), _double_value(0.0), BSONInt32(0), BSONDecimal128("0")]) + groups.append([_double_value(float_min_value)]) + groups.append([_int_value(1), _double_value(1.0), BSONInt32(1), BSONDecimal128("1")]) + groups.append([_double_value(1.1)]) + groups.append([_int_value(int_max_value), BSONInt32(int_max_value)]) + groups.append([_int_value(int_max_value + 1)]) + groups.append([_double_value(inf)]) + # NaNs sort after +Infinity + groups.append([_double_value(float_nan), _double_value(float_nan), BSONDecimal128("NaN")]) + + # Rank 5: Native Timestamps + groups.append([_timestamp_value(123, 0)]) + groups.append([_timestamp_value(123, 123)]) + groups.append([_timestamp_value(345, 0)]) + + # Rank 6: BSONTimestamp + groups.append([BSONTimestamp(100, 1)]) + groups.append([BSONTimestamp(100, 2)]) + groups.append([BSONTimestamp(200, 0)]) + + # Rank 7: Strings + groups.append([_string_value("")]) + groups.append([_string_value("a")]) + groups.append([_string_value("abc def")]) + + # Rank 8: Blobs + groups.append([_blob_value(b"")]) + groups.append([_blob_value(b"\x00")]) + groups.append([_blob_value(b"\x7f")]) + + # Rank 9: BSONBinary + groups.append([BSONBinary(b"\x00", subtype=0)]) + groups.append([BSONBinary(b"\x00", subtype=1)]) + groups.append([BSONBinary(b"\x01", subtype=0)]) + + # Rank 10: Refs / Resource names + groups.append([_reference_value("projects/p1/databases/d1/documents/c1/doc1")]) + groups.append([_reference_value("projects/p1/databases/d1/documents/c1/doc2")]) + + # Rank 11: BSONObjectID + groups.append([BSONObjectID("000000000000000000000001")]) + groups.append([BSONObjectID("507f1f77bcf86cd799439011")]) + + # Rank 12: GeoPoint + groups.append([_geoPoint_value(-90, -180)]) + groups.append([_geoPoint_value(0, 0)]) + groups.append([_geoPoint_value(90, 180)]) + + # Rank 13: BSONRegex + groups.append([BSONRegex("a", "i")]) + groups.append([BSONRegex("a", "m")]) + groups.append([BSONRegex("b", "i")]) + + # Rank 14: Arrays + groups.append([_array_value()]) + groups.append([_array_value(["bar"])]) + groups.append([_array_value(["foo"])]) + + # Rank 15: Vectors + groups.append([_object_value({"__type__": "__vector__", "value": [3.0, 2.0]})]) + groups.append([_object_value({"__type__": "__vector__", "value": [1.0, 2.0, 5.0]})]) + + # Rank 16: Objects + groups.append([_object_value({"bar": 0})]) + groups.append([_object_value({"bar": 1})]) + + # Rank 17: MaxKey + groups.append([BSONMaxKey()]) target = _make_order() @@ -142,118 +136,89 @@ def test_order_compare_across_heterogenous_values(): for j in range(len(groups)): for right in groups[j]: expected = Order._compare_to(i, j) - assert target.compare(left, right) == expected + res = target.compare(left, right) + assert res == expected, f"Failed comparing group {i} ({left!r}) vs group {j} ({right!r}), got {res}, expected {expected}" - expected = Order._compare_to(j, i) - assert target.compare(right, left) == expected +def test_order_all_value_present(): + for type_order in TypeOrder: + assert type_order in _TYPE_ORDER_MAP -def test_order_compare_w_typeorder_type_failure(): - target = _make_order() - left = mock.Mock() - left.WhichOneof.return_value = "imaginary-type" - with pytest.raises(ValueError) as exc_info: - target.compare(left, mock.Mock()) +def test_order_nan_bypassing_and_precision(): + target = _make_order() + nan_decimal = BSONDecimal128("NaN") + inf_decimal = BSONDecimal128("Infinity") + normal_decimal = BSONDecimal128("123.45678901234567890123456789") - (message,) = exc_info.value.args - assert message.startswith("Could not detect value") + # NaN sorts after +Infinity + assert target.compare(nan_decimal, inf_decimal) == 1 + assert target.compare(inf_decimal, nan_decimal) == -1 + assert target.compare(nan_decimal, nan_decimal) == 0 + # Decimal vs int comparison equality + assert target.compare(BSONInt32(5), BSONDecimal128("5.0")) == 0 + assert target.compare(BSONInt32(5), 5) == 0 -def test_order_compare_w_failure_to_find_type(): - from google.cloud.firestore_v1.order import TypeOrder +def test_order_compare_raw_legacy_single_key_dict(): target = _make_order() - left = mock.Mock() - left.WhichOneof.return_value = "imaginary-type" - right = mock.Mock() - # Patch from value to get to the deep compare. Since left is a bad type - # expect this to fail with value error. - with mock.patch.object(TypeOrder, "from_value") as to: - to.value = None - with pytest.raises(ValueError) as exc_info: - target.compare(left, right) - - (message,) = exc_info.value.args - assert message.startswith("Unknown TypeOrder") - - -@pytest.mark.parametrize("invalid_point_is_left", [True, False]) -def test_order_compare_invalid_geo_points(invalid_point_is_left): - """ - comparing invalid geopoints should raise exception - """ - target = _make_order() - points = [_array_value(), _geoPoint_value(10, 10)] - if not invalid_point_is_left: - # reverse points - points = points[::-1] - with pytest.raises(AttributeError): - target.compare_geo_points(*points) + oid1 = {"__oid__": "507f1f77bcf86cd799439011"} + oid2 = {"__oid__": "507f1f77bcf86cd799439011"} + assert target.compare(oid1, oid2) == 0 -def test_order_all_value_present(): - from google.cloud.firestore_v1.order import _TYPE_ORDER_MAP, TypeOrder - - for type_order in TypeOrder: - assert type_order in _TYPE_ORDER_MAP - +def test_bson_binary_sorting_order(): + target = _make_order() + b1 = BSONBinary(b"aaa", subtype=2) + b2 = BSONBinary(b"bbb", subtype=0) + b3 = BSONBinary(b"aaa", subtype=3) -def test_order_compare_w_objects_different_keys(): - left = _object_value({"foo": 0}) - right = _object_value({"bar": 0}) + # Data bytes comparison first + assert target.compare(b1, b2) == -1 - target = _make_order() - target.compare(left, right) + # Subtype as secondary tie-breaker + assert target.compare(b1, b3) == -1 def _boolean_value(b): from google.cloud.firestore_v1._helpers import encode_value - return encode_value(b) def _double_value(d): from google.cloud.firestore_v1._helpers import encode_value - return encode_value(d) def _int_value(value): from google.cloud.firestore_v1._helpers import encode_value - return encode_value(value) def _string_value(s): from google.cloud.firestore_v1._helpers import encode_value - return encode_value(s) def _reference_value(r): from google.cloud.firestore_v1.types import document - return document.Value(reference_value=r) def _blob_value(b): from google.cloud.firestore_v1._helpers import encode_value - return encode_value(b) def nullValue(): from google.cloud.firestore_v1._helpers import encode_value - return encode_value(None) def _timestamp_value(seconds, nanos): from google.protobuf import timestamp_pb2 - from google.cloud.firestore_v1.types import document - return document.Value( timestamp_value=timestamp_pb2.Timestamp(seconds=seconds, nanos=nanos) ) @@ -261,17 +226,14 @@ def _timestamp_value(seconds, nanos): def _geoPoint_value(latitude, longitude): from google.cloud.firestore_v1._helpers import GeoPoint, encode_value - return encode_value(GeoPoint(latitude, longitude)) def _array_value(values=[]): from google.cloud.firestore_v1._helpers import encode_value - return encode_value(values) def _object_value(keysAndValues): from google.cloud.firestore_v1._helpers import encode_value - return encode_value(keysAndValues)