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..40b7ef346566 100644 --- a/packages/google-cloud-firestore/google/cloud/firestore_v1/bson.py +++ b/packages/google-cloud-firestore/google/cloud/firestore_v1/bson.py @@ -35,6 +35,7 @@ "BSONInt32", "BSONBinary", "BSONTimestamp", + "BSONRegex", ] _OBJECT_ID_BYTES_LEN = 12 @@ -342,3 +343,85 @@ 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 (Union[str, re.RegexFlag, int], optional): BSON regex option flags + as a string (e.g. "i", "m", "s") or Python `re` flag integer (e.g. `re.I | re.M`). + Defaults to "". + + Raises: + TypeError: If pattern is not a string or options is invalid type. + + Example: + >>> regex = BSONRegex("^hello.*$", options="i") + >>> regex.pattern + '^hello.*$' + >>> regex.options + 'i' + """ + + __slots__ = ("_pattern", "_options") + + _FLAG_TO_OPTION: Dict[int, str] = { + re.IGNORECASE: "i", + re.LOCALE: "l", + re.MULTILINE: "m", + re.DOTALL: "s", + re.UNICODE: "u", + re.VERBOSE: "x", + } + + def __init__(self, pattern: str, options: Union[str, re.RegexFlag, int] = ""): + if not isinstance(pattern, str): + raise TypeError("BSONRegex pattern must be a str.") + + if isinstance(options, bool): + raise TypeError("BSONRegex options must be a str or re flag integer.") + + if isinstance(options, str): + self._options: str = "".join(sorted(set(options))) + elif isinstance(options, int): + opts = [] + for flag, char in self._FLAG_TO_OPTION.items(): + if options & flag: + opts.append(char) + self._options = "".join(sorted(opts)) + else: + raise TypeError("BSONRegex options must be a str or re flag integer.") + + self._pattern: str = pattern + + @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..b5be269f0061 100644 --- a/packages/google-cloud-firestore/tests/unit/v1/test_bson.py +++ b/packages/google-cloud-firestore/tests/unit/v1/test_bson.py @@ -17,6 +17,7 @@ import copy import pickle +import re import pytest @@ -26,6 +27,7 @@ BSONMaxKey, BSONMinKey, BSONObjectId, + BSONRegex, BSONTimestamp, _BSONType, ) @@ -411,3 +413,72 @@ 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" + + +def test_bson_regex_options_from_re_flags(): + rx = BSONRegex("foo", options=re.IGNORECASE | re.MULTILINE) + assert rx.options == "im" + + +@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", True, TypeError, "options must be a str or re flag integer"), + ("foo", [1, 2], TypeError, "options must be a str or re flag integer"), + ], +) +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