diff --git a/.librarian/generator-input/client-post-processing/firestore-integration.yaml b/.librarian/generator-input/client-post-processing/firestore-integration.yaml index 504cc73bd4ee..429d658d8e96 100644 --- a/.librarian/generator-input/client-post-processing/firestore-integration.yaml +++ b/.librarian/generator-input/client-post-processing/firestore-integration.yaml @@ -75,6 +75,7 @@ replacements: BSONMaxKey, BSONMinKey, BSONObjectId, + BSONTimestamp, ) from google.cloud.firestore_v1.client import Client from google.cloud.firestore_v1.collection import CollectionReference @@ -182,6 +183,7 @@ replacements: "BSONMaxKey", "BSONMinKey", "BSONObjectId", + "BSONTimestamp", "Client", "CountAggregation", "CollectionGroup", @@ -259,6 +261,7 @@ replacements: BSONMaxKey, BSONMinKey, BSONObjectId, + BSONTimestamp, Client, CollectionGroup, CollectionReference, @@ -321,6 +324,7 @@ replacements: "BSONMaxKey", "BSONMinKey", "BSONObjectId", + "BSONTimestamp", "Client", "CountAggregation", "CollectionGroup", diff --git a/packages/google-cloud-firestore/google/cloud/firestore/__init__.py b/packages/google-cloud-firestore/google/cloud/firestore/__init__.py index 256280dbefd8..e89628a94ac1 100644 --- a/packages/google-cloud-firestore/google/cloud/firestore/__init__.py +++ b/packages/google-cloud-firestore/google/cloud/firestore/__init__.py @@ -40,6 +40,7 @@ BSONMaxKey, BSONMinKey, BSONObjectId, + BSONTimestamp, Client, CollectionGroup, CollectionReference, @@ -102,6 +103,7 @@ "BSONMaxKey", "BSONMinKey", "BSONObjectId", + "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 0605bb8edbe8..9d629e864507 100644 --- a/packages/google-cloud-firestore/google/cloud/firestore_v1/__init__.py +++ b/packages/google-cloud-firestore/google/cloud/firestore_v1/__init__.py @@ -52,6 +52,7 @@ BSONMaxKey, BSONMinKey, BSONObjectId, + BSONTimestamp, ) from google.cloud.firestore_v1.client import Client from google.cloud.firestore_v1.collection import CollectionReference @@ -159,6 +160,7 @@ "BSONMaxKey", "BSONMinKey", "BSONObjectId", + "BSONTimestamp", "Client", "CountAggregation", "CollectionGroup", diff --git a/packages/google-cloud-firestore/google/cloud/firestore_v1/bson.py b/packages/google-cloud-firestore/google/cloud/firestore_v1/bson.py index 981450d00ef9..fecf9d2aea7c 100644 --- a/packages/google-cloud-firestore/google/cloud/firestore_v1/bson.py +++ b/packages/google-cloud-firestore/google/cloud/firestore_v1/bson.py @@ -34,6 +34,7 @@ "BSONMaxKey", "BSONInt32", "BSONBinary", + "BSONTimestamp", ] _OBJECT_ID_BYTES_LEN = 12 @@ -280,3 +281,64 @@ def __eq__(self, other: Any) -> bool: def __hash__(self) -> int: return hash((type(self), self._data, self._subtype)) + + +class BSONTimestamp(_BSONType): + """Container for BSON Timestamp values. + + Args: + seconds (int): Seconds count. + increment (int): Increment/ordinal. + + Raises: + TypeError: If seconds or increment is not an int or is a bool. + + Example: + >>> ts = BSONTimestamp(1700000000, 1) + >>> ts.seconds + 1700000000 + >>> ts.increment + 1 + """ + + __slots__ = ("_seconds", "_increment") + + def __init__(self, seconds: int, increment: int): + if isinstance(seconds, bool) or not isinstance(seconds, int): + raise TypeError("BSONTimestamp seconds must be an int.") + if isinstance(increment, bool) or not isinstance(increment, int): + raise TypeError("BSONTimestamp increment must be an int.") + self._seconds: int = seconds + self._increment: int = increment + + @property + def seconds(self) -> int: + """int: The seconds value.""" + return self._seconds + + @property + def increment(self) -> int: + """int: The increment value.""" + return self._increment + + def _to_map_value(self) -> Dict[str, Dict[str, int]]: + """Returns map dictionary representation for wire serialization.""" + return { + "__request_timestamp__": { + "seconds": self._seconds, + "increment": 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 == other._seconds and self._increment == other._increment + ) + return NotImplemented + + def __hash__(self) -> int: + return hash((type(self), self._seconds, self._increment)) diff --git a/packages/google-cloud-firestore/tests/system/test_system.py b/packages/google-cloud-firestore/tests/system/test_system.py index b5658ba4158f..7997c95265b3 100644 --- a/packages/google-cloud-firestore/tests/system/test_system.py +++ b/packages/google-cloud-firestore/tests/system/test_system.py @@ -54,6 +54,7 @@ BSONMaxKey, BSONMinKey, BSONObjectId, + BSONTimestamp, ) from google.cloud.firestore_v1.vector import Vector @@ -1294,6 +1295,7 @@ def test_bson_document_writes(client, cleanup, database): "max_key": BSONMaxKey(), "int32_val": BSONInt32(42), "binary_val_sub128": BSONBinary(b"world", subtype=128), + "timestamp_val": BSONTimestamp(1700000000, 1), } doc_ref.set(bson_payload) @@ -1306,6 +1308,12 @@ def test_bson_document_writes(client, cleanup, database): "max_key": {"__max__": None}, "int32_val": {"__int__": 42}, "binary_val_sub128": {"__binary__": b"\x80world"}, + "timestamp_val": { + "__request_timestamp__": { + "seconds": 1700000000, + "increment": 1, + } + }, } diff --git a/packages/google-cloud-firestore/tests/system/test_system_async.py b/packages/google-cloud-firestore/tests/system/test_system_async.py index 5d7dddc4a01e..24ebf2992ea0 100644 --- a/packages/google-cloud-firestore/tests/system/test_system_async.py +++ b/packages/google-cloud-firestore/tests/system/test_system_async.py @@ -57,6 +57,7 @@ BSONMaxKey, BSONMinKey, BSONObjectId, + BSONTimestamp, ) from google.cloud.firestore_v1.query_profile import ( ExecutionStats, @@ -1267,6 +1268,7 @@ async def test_async_bson_document_writes(client, cleanup, database): "max_key": BSONMaxKey(), "int32_val": BSONInt32(42), "binary_val_sub128": BSONBinary(b"world", subtype=128), + "timestamp_val": BSONTimestamp(1700000000, 1), } await doc_ref.set(bson_payload) @@ -1279,6 +1281,12 @@ async def test_async_bson_document_writes(client, cleanup, database): "max_key": {"__max__": None}, "int32_val": {"__int__": 42}, "binary_val_sub128": {"__binary__": b"\x80world"}, + "timestamp_val": { + "__request_timestamp__": { + "seconds": 1700000000, + "increment": 1, + } + }, } diff --git a/packages/google-cloud-firestore/tests/unit/v1/test_bson.py b/packages/google-cloud-firestore/tests/unit/v1/test_bson.py index 806e7d9cbc4c..d90328d4cc1c 100644 --- a/packages/google-cloud-firestore/tests/unit/v1/test_bson.py +++ b/packages/google-cloud-firestore/tests/unit/v1/test_bson.py @@ -26,6 +26,7 @@ BSONMaxKey, BSONMinKey, BSONObjectId, + BSONTimestamp, _BSONType, ) @@ -345,3 +346,68 @@ def test_bson_binary_copy(): def test_bson_binary_pickle(): val = BSONBinary(b"hello", subtype=5) assert pickle.loads(pickle.dumps(val)) == val + + +def test_bson_timestamp_valid(): + ts = BSONTimestamp(1700000000, 42) + assert ts.seconds == 1700000000 + assert ts.increment == 42 + assert ts._to_map_value() == { + "__request_timestamp__": { + "seconds": 1700000000, + "increment": 42, + } + } + assert repr(ts) == "BSONTimestamp(seconds=1700000000, increment=42)" + + +def test_bson_timestamp_boundaries(): + ts_min = BSONTimestamp(0, 0) + ts_max = BSONTimestamp(4294967295, 4294967295) + assert ts_min.seconds == 0 + assert ts_min.increment == 0 + assert ts_max.seconds == 4294967295 + assert ts_max.increment == 4294967295 + + +@pytest.mark.parametrize( + "sec_input, inc_input, exc_type, match_msg", + [ + (True, 0, TypeError, "seconds must be an int"), + (0, False, TypeError, "increment must be an int"), + ("1700000000", 0, TypeError, "seconds must be an int"), + (0, 1.5, TypeError, "increment must be an int"), + ], +) +def test_bson_timestamp_invalid_inputs(sec_input, inc_input, exc_type, match_msg): + with pytest.raises(exc_type, match=match_msg): + BSONTimestamp(sec_input, inc_input) + + +def test_bson_timestamp_equality(): + ts1 = BSONTimestamp(100, 1) + ts2 = BSONTimestamp(100, 1) + ts3 = BSONTimestamp(100, 2) + ts4 = BSONTimestamp(200, 1) + assert ts1 == ts2 + assert ts1 != ts3 + assert ts1 != ts4 + assert ts1 != 100 + + +def test_bson_timestamp_hash_and_dict_key(): + ts1 = BSONTimestamp(100, 1) + ts2 = BSONTimestamp(100, 1) + assert hash(ts1) == hash(ts2) + assert len({ts1, ts2}) == 1 + + +def test_bson_timestamp_copy(): + ts = BSONTimestamp(100, 1) + assert copy.copy(ts) == ts + assert copy.deepcopy(ts) == ts + + +def test_bson_timestamp_pickle(): + ts = BSONTimestamp(100, 1) + assert pickle.loads(pickle.dumps(ts)) == ts