From 25858413f5973c2296b2368da1f5f3d3ac2ec4ae Mon Sep 17 00:00:00 2001 From: ohmayr Date: Wed, 16 Sep 2026 08:43:41 +0000 Subject: [PATCH 1/2] feat(firestore): add BSONRegex support --- .../firestore-integration.yaml | 4 + .../google/cloud/firestore/__init__.py | 2 + .../google/cloud/firestore_v1/__init__.py | 2 + .../google/cloud/firestore_v1/bson.py | 83 +++++++++++++++++++ .../tests/system/test_system.py | 8 ++ .../tests/system/test_system_async.py | 8 ++ .../tests/unit/v1/test_bson.py | 71 ++++++++++++++++ 7 files changed, 178 insertions(+) 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 From cff02d265b908199ebaa28f024d877af54703298 Mon Sep 17 00:00:00 2001 From: ohmayr Date: Mon, 21 Sep 2026 08:13:16 +0000 Subject: [PATCH 2/2] refactor(firestore): address review feedback for BSONRegex options validation - Restrict BSONRegex options parameter to string only, removing re.RegexFlag and int support. - Add client-side validation for BSON regex option characters ("i", "m", "s", "x", "u", "a"), raising ValueError on invalid options. - Remove obsolete re-flag test and clean up unused import in test_bson.py. --- .../google/cloud/firestore_v1/bson.py | 44 +++++++------------ .../tests/unit/v1/test_bson.py | 13 +++--- 2 files changed, 22 insertions(+), 35 deletions(-) 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 40b7ef346566..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", @@ -350,12 +350,12 @@ class BSONRegex(_BSONType): 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 "". + 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 invalid type. + 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") @@ -367,34 +367,24 @@ class BSONRegex(_BSONType): __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", - } + _VALID_OPTIONS: FrozenSet[str] = frozenset({"i", "m", "s", "x", "u", "a"}) - def __init__(self, pattern: str, options: Union[str, re.RegexFlag, int] = ""): + def __init__(self, pattern: str, options: str = ""): 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.") + 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: 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 b5be269f0061..4d7610892357 100644 --- a/packages/google-cloud-firestore/tests/unit/v1/test_bson.py +++ b/packages/google-cloud-firestore/tests/unit/v1/test_bson.py @@ -17,7 +17,6 @@ import copy import pickle -import re import pytest @@ -436,18 +435,16 @@ def test_bson_regex_options_sorting_and_deduplication(): 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"), + ("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):