Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
Original file line number Diff line number Diff line change
Expand Up @@ -75,6 +75,7 @@ replacements:
BSONMaxKey,
BSONMinKey,
BSONObjectId,
BSONRegex,
BSONTimestamp,
)
from google.cloud.firestore_v1.client import Client
Expand Down Expand Up @@ -183,6 +184,7 @@ replacements:
"BSONMaxKey",
"BSONMinKey",
"BSONObjectId",
"BSONRegex",
"BSONTimestamp",
"Client",
"CountAggregation",
Expand Down Expand Up @@ -261,6 +263,7 @@ replacements:
BSONMaxKey,
BSONMinKey,
BSONObjectId,
BSONRegex,
BSONTimestamp,
Client,
CollectionGroup,
Expand Down Expand Up @@ -324,6 +327,7 @@ replacements:
"BSONMaxKey",
"BSONMinKey",
"BSONObjectId",
"BSONRegex",
"BSONTimestamp",
"Client",
"CountAggregation",
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -40,6 +40,7 @@
BSONMaxKey,
BSONMinKey,
BSONObjectId,
BSONRegex,
BSONTimestamp,
Client,
CollectionGroup,
Expand Down Expand Up @@ -103,6 +104,7 @@
"BSONMaxKey",
"BSONMinKey",
"BSONObjectId",
"BSONRegex",
"BSONTimestamp",
"Client",
"CountAggregation",
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -52,6 +52,7 @@
BSONMaxKey,
BSONMinKey,
BSONObjectId,
BSONRegex,
BSONTimestamp,
)
from google.cloud.firestore_v1.client import Client
Expand Down Expand Up @@ -160,6 +161,7 @@
"BSONMaxKey",
"BSONMinKey",
"BSONObjectId",
"BSONRegex",
"BSONTimestamp",
"Client",
"CountAggregation",
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -35,6 +35,7 @@
"BSONInt32",
"BSONBinary",
"BSONTimestamp",
"BSONRegex",
]

_OBJECT_ID_BYTES_LEN = 12
Expand Down Expand Up @@ -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] = ""):

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

I'd recommend dropping int and re.RegexFlag, since we only support a subset of the flags. It might give users the wrong expectations. And other languages only support strings here

I don't have a deep understanding of this though, so you you feel confident about this, we can keep it

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))
8 changes: 8 additions & 0 deletions packages/google-cloud-firestore/tests/system/test_system.py
Original file line number Diff line number Diff line change
Expand Up @@ -54,6 +54,7 @@
BSONMaxKey,
BSONMinKey,
BSONObjectId,
BSONRegex,
BSONTimestamp,
)
from google.cloud.firestore_v1.vector import Vector
Expand Down Expand Up @@ -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"),

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Can you add a test case with invalid options? Like including l?

We should make sure the library handles it well, and gives a clear error message

}

doc_ref.set(bson_payload)
Expand All @@ -1314,6 +1316,12 @@ def test_bson_document_writes(client, cleanup, database):
"increment": 1,
}
},
"regex_val": {
"__regex__": {
"pattern": "^hello.*$",
"options": "i",
}
},
}


Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -57,6 +57,7 @@
BSONMaxKey,
BSONMinKey,
BSONObjectId,
BSONRegex,
BSONTimestamp,
)
from google.cloud.firestore_v1.query_profile import (
Expand Down Expand Up @@ -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)
Expand All @@ -1287,6 +1289,12 @@ async def test_async_bson_document_writes(client, cleanup, database):
"increment": 1,
}
},
"regex_val": {
"__regex__": {
"pattern": "^hello.*$",
"options": "i",
}
},
}


Expand Down
71 changes: 71 additions & 0 deletions packages/google-cloud-firestore/tests/unit/v1/test_bson.py
Original file line number Diff line number Diff line change
Expand Up @@ -17,6 +17,7 @@

import copy
import pickle
import re

import pytest

Expand All @@ -26,6 +27,7 @@
BSONMaxKey,
BSONMinKey,
BSONObjectId,
BSONRegex,
BSONTimestamp,
_BSONType,
)
Expand Down Expand Up @@ -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
Loading