From 420014f464774997f2684def65e9306e1390a204 Mon Sep 17 00:00:00 2001 From: ohmayr Date: Wed, 16 Sep 2026 08:43:41 +0000 Subject: [PATCH 1/3] 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 c05d032ea46792c1c46fbf751025eb1a404e1506 Mon Sep 17 00:00:00 2001 From: ohmayr Date: Mon, 21 Sep 2026 08:13:16 +0000 Subject: [PATCH 2/3] 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): From 01697b94e34cdbd6846a975ac7437595207c39e9 Mon Sep 17 00:00:00 2001 From: ohmayr Date: Tue, 22 Sep 2026 22:58:54 +0000 Subject: [PATCH 3/3] refactor(firestore): defer BSONRegex options validation to backend Remove client-side regex options character validation and whitelist in BSONRegex, letting the Firestore backend validate regex option flags. Preserves alphabetical sorting and deduplication per BSON specification while avoiding unhandled ValueError exceptions during read deserialization when documents contain standard BSON options like 'l' (locale). Fixes: b/562163604 --- .../google/cloud/firestore_v1/bson.py | 14 ++------------ .../tests/unit/v1/test_bson.py | 5 +++-- 2 files changed, 5 insertions(+), 14 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 f887a6491d83..0c4b8dd00b9d 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, FrozenSet, Union +from typing import Any, Dict, Union __all__ = [ "BSONObjectId", @@ -351,11 +351,10 @@ class BSONRegex(_BSONType): 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 "". + (e.g. "i", "m", "s", "x", "u"). 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") @@ -367,8 +366,6 @@ class BSONRegex(_BSONType): __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.") @@ -376,13 +373,6 @@ def __init__(self, pattern: str, options: 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))) 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 4d7610892357..79654e1669cb 100644 --- a/packages/google-cloud-firestore/tests/unit/v1/test_bson.py +++ b/packages/google-cloud-firestore/tests/unit/v1/test_bson.py @@ -434,6 +434,9 @@ def test_bson_regex_options_sorting_and_deduplication(): rx2 = BSONRegex("foo", options="mmiis") assert rx2.options == "ims" + rx3 = BSONRegex("foo", options="xl") + assert rx3.options == "lx" + @pytest.mark.parametrize( "pattern_input, options_input, exc_type, match_msg", @@ -443,8 +446,6 @@ def test_bson_regex_options_sorting_and_deduplication(): ("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):