diff --git a/.librarian/generator-input/client-post-processing/firestore-integration.yaml b/.librarian/generator-input/client-post-processing/firestore-integration.yaml index 429d658d8e96..49fd20d5f427 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, + BSONRegex, BSONTimestamp, ) from google.cloud.firestore_v1.client import Client @@ -183,6 +184,7 @@ replacements: "BSONMaxKey", "BSONMinKey", "BSONObjectId", + "BSONRegex", "BSONTimestamp", "Client", "CountAggregation", @@ -261,6 +263,7 @@ replacements: BSONMaxKey, BSONMinKey, BSONObjectId, + BSONRegex, BSONTimestamp, Client, CollectionGroup, @@ -324,6 +327,7 @@ replacements: "BSONMaxKey", "BSONMinKey", "BSONObjectId", + "BSONRegex", "BSONTimestamp", "Client", "CountAggregation", diff --git a/packages/google-cloud-firestore/google/cloud/firestore/__init__.py b/packages/google-cloud-firestore/google/cloud/firestore/__init__.py index e89628a94ac1..eaa2daacd0f1 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, + BSONRegex, BSONTimestamp, Client, CollectionGroup, @@ -103,6 +104,7 @@ "BSONMaxKey", "BSONMinKey", "BSONObjectId", + "BSONRegex", "BSONTimestamp", "Client", "CountAggregation", 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 9d629e864507..e7445eeedf3a 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, + BSONRegex, BSONTimestamp, ) from google.cloud.firestore_v1.client import Client @@ -160,6 +161,7 @@ "BSONMaxKey", "BSONMinKey", "BSONObjectId", + "BSONRegex", "BSONTimestamp", "Client", "CountAggregation", 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 fecf9d2aea7c..f887a6491d83 100644 --- a/packages/google-cloud-firestore/google/cloud/firestore_v1/bson.py +++ b/packages/google-cloud-firestore/google/cloud/firestore_v1/bson.py @@ -26,7 +26,7 @@ import abc import re -from typing import Any, Dict, Union +from typing import Any, Dict, FrozenSet, Union __all__ = [ "BSONObjectId", @@ -35,6 +35,7 @@ "BSONInt32", "BSONBinary", "BSONTimestamp", + "BSONRegex", ] _OBJECT_ID_BYTES_LEN = 12 @@ -342,3 +343,75 @@ def __eq__(self, other: Any) -> bool: def __hash__(self) -> int: return hash((type(self), self._seconds, self._increment)) + + +class BSONRegex(_BSONType): + """Represents a BSON Regular Expression container for Firestore. + + Args: + pattern (str): The regular expression pattern string. + options (str, optional): BSON regex option flags as a string + (e.g. "i", "m", "s", "x", "u", "a"). Defaults to "". + + Raises: + TypeError: If pattern is not a string or options is not a string. + ValueError: If options contains invalid BSON regex flag characters. + + Example: + >>> regex = BSONRegex("^hello.*$", options="i") + >>> regex.pattern + '^hello.*$' + >>> regex.options + 'i' + """ + + __slots__ = ("_pattern", "_options") + + _VALID_OPTIONS: FrozenSet[str] = frozenset({"i", "m", "s", "x", "u", "a"}) + + def __init__(self, pattern: str, options: str = ""): + if not isinstance(pattern, str): + raise TypeError("BSONRegex pattern must be a str.") + + if not isinstance(options, str): + raise TypeError("BSONRegex options must be a str.") + + invalid = set(options) - self._VALID_OPTIONS + if invalid: + raise ValueError( + f"Invalid BSON regex option(s): {sorted(invalid)}. " + f"Valid options are: {sorted(self._VALID_OPTIONS)}" + ) + + self._pattern: str = pattern + self._options: str = "".join(sorted(set(options))) + + @property + def pattern(self) -> str: + """str: The regular expression pattern string.""" + return self._pattern + + @property + def options(self) -> str: + """str: The normalized BSON regex option flags sorted alphabetically.""" + return self._options + + def _to_map_value(self) -> Dict[str, Dict[str, str]]: + """Returns map dictionary representation for wire serialization.""" + return { + "__regex__": { + "pattern": self._pattern, + "options": self._options, + } + } + + def __repr__(self) -> str: + return f"BSONRegex({self._pattern!r}, options={self._options!r})" + + def __eq__(self, other: Any) -> bool: + if isinstance(other, BSONRegex): + return self._pattern == other._pattern and self._options == other._options + return NotImplemented + + def __hash__(self) -> int: + return hash((type(self), self._pattern, self._options)) diff --git a/packages/google-cloud-firestore/tests/system/test_system.py b/packages/google-cloud-firestore/tests/system/test_system.py index 7997c95265b3..bee648ace9b3 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, + BSONRegex, BSONTimestamp, ) from google.cloud.firestore_v1.vector import Vector @@ -1296,6 +1297,7 @@ def test_bson_document_writes(client, cleanup, database): "int32_val": BSONInt32(42), "binary_val_sub128": BSONBinary(b"world", subtype=128), "timestamp_val": BSONTimestamp(1700000000, 1), + "regex_val": BSONRegex("^hello.*$", options="i"), } doc_ref.set(bson_payload) @@ -1314,6 +1316,12 @@ def test_bson_document_writes(client, cleanup, database): "increment": 1, } }, + "regex_val": { + "__regex__": { + "pattern": "^hello.*$", + "options": "i", + } + }, } 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 24ebf2992ea0..479fa66ee860 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, + BSONRegex, BSONTimestamp, ) from google.cloud.firestore_v1.query_profile import ( @@ -1269,6 +1270,7 @@ async def test_async_bson_document_writes(client, cleanup, database): "int32_val": BSONInt32(42), "binary_val_sub128": BSONBinary(b"world", subtype=128), "timestamp_val": BSONTimestamp(1700000000, 1), + "regex_val": BSONRegex("^hello.*$", options="i"), } await doc_ref.set(bson_payload) @@ -1287,6 +1289,12 @@ async def test_async_bson_document_writes(client, cleanup, database): "increment": 1, } }, + "regex_val": { + "__regex__": { + "pattern": "^hello.*$", + "options": "i", + } + }, } 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 d90328d4cc1c..4d7610892357 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, + BSONRegex, BSONTimestamp, _BSONType, ) @@ -411,3 +412,70 @@ def test_bson_timestamp_copy(): def test_bson_timestamp_pickle(): ts = BSONTimestamp(100, 1) assert pickle.loads(pickle.dumps(ts)) == ts + + +def test_bson_regex_valid(): + rx = BSONRegex("^hello.*$", options="i") + assert rx.pattern == "^hello.*$" + assert rx.options == "i" + assert rx._to_map_value() == { + "__regex__": { + "pattern": "^hello.*$", + "options": "i", + } + } + assert repr(rx) == "BSONRegex('^hello.*$', options='i')" + + +def test_bson_regex_options_sorting_and_deduplication(): + rx1 = BSONRegex("foo", options="msi") + assert rx1.options == "ims" + + rx2 = BSONRegex("foo", options="mmiis") + assert rx2.options == "ims" + + +@pytest.mark.parametrize( + "pattern_input, options_input, exc_type, match_msg", + [ + (123, "i", TypeError, "pattern must be a str"), + (None, "i", TypeError, "pattern must be a str"), + ("foo", 123, TypeError, "options must be a str"), + ("foo", True, TypeError, "options must be a str"), + ("foo", [1, 2], TypeError, "options must be a str"), + ("foo", "l", ValueError, "Invalid BSON regex option"), + ("foo", "invalid", ValueError, "Invalid BSON regex option"), + ], +) +def test_bson_regex_invalid_inputs(pattern_input, options_input, exc_type, match_msg): + with pytest.raises(exc_type, match=match_msg): + BSONRegex(pattern_input, options_input) + + +def test_bson_regex_equality(): + rx1 = BSONRegex("^abc", options="i") + rx2 = BSONRegex("^abc", options="i") + rx3 = BSONRegex("^abc", options="m") + rx4 = BSONRegex("^xyz", options="i") + assert rx1 == rx2 + assert rx1 != rx3 + assert rx1 != rx4 + assert rx1 != "^abc" + + +def test_bson_regex_hash_and_dict_key(): + rx1 = BSONRegex("^abc", options="i") + rx2 = BSONRegex("^abc", options="i") + assert hash(rx1) == hash(rx2) + assert len({rx1, rx2}) == 1 + + +def test_bson_regex_copy(): + rx = BSONRegex("^abc", options="i") + assert copy.copy(rx) == rx + assert copy.deepcopy(rx) == rx + + +def test_bson_regex_pickle(): + rx = BSONRegex("^abc", options="i") + assert pickle.loads(pickle.dumps(rx)) == rx