Skip to content

Commit 9a60436

Browse files
committed
fix(firestore): separate BSON_TIMESTAMP in TypeOrder and optimize compare_numbers
Separate BSON_TIMESTAMP from native TIMESTAMP to conform to the cross-SDK 17-rank TypeOrder specification. Restore compare_timestamps to native Firestore timestamps and introduce compare_bson_timestamps. Extract module-level _to_number and _is_nan helpers, directly inspecting protobuf fields to optimize numeric comparisons.
1 parent 5b0f342 commit 9a60436

3 files changed

Lines changed: 90 additions & 49 deletions

File tree

‎packages/google-cloud-firestore/google/cloud/firestore_v1/order.py‎

Lines changed: 79 additions & 47 deletions
Original file line numberDiff line numberDiff line change
@@ -20,6 +20,45 @@
2020
from google.cloud.firestore_v1._helpers import GeoPoint, decode_value
2121

2222

23+
def _to_number(val: Any) -> Any:
24+
"""Extract a numeric value (int, float, Decimal) from a Value protobuf or Python value.
25+
26+
Directly inspects the protobuf value_type without calling decode_value()
27+
for optimal performance.
28+
"""
29+
value_pb = getattr(val, "_pb", val)
30+
which = (
31+
value_pb.WhichOneof("value_type") if hasattr(value_pb, "WhichOneof") else None
32+
)
33+
34+
if which == "integer_value":
35+
return value_pb.integer_value
36+
elif which == "double_value":
37+
return value_pb.double_value
38+
elif which == "map_value":
39+
fields = value_pb.map_value.fields
40+
if "__int__" in fields:
41+
return fields["__int__"].integer_value
42+
elif "__decimal128__" in fields:
43+
return decimal.Decimal(fields["__decimal128__"].string_value)
44+
45+
num = decode_value(val, None)
46+
to_decimal = getattr(num, "to_decimal", None)
47+
return to_decimal() if callable(to_decimal) else getattr(num, "value", num)
48+
49+
50+
def _is_nan(val: Any) -> bool:
51+
"""Check if a numeric value is NaN, safely handling OverflowError and non-floats."""
52+
if hasattr(val, "is_nan"):
53+
return val.is_nan()
54+
if isinstance(val, (int, decimal.Decimal)):
55+
return False
56+
try:
57+
return math.isnan(val)
58+
except (TypeError, OverflowError):
59+
return False
60+
61+
2362
class TypeOrder(Enum):
2463
"""The supported Data Type.
2564
@@ -42,6 +81,7 @@ class TypeOrder(Enum):
4281
BSON_OBJECT_ID = 13
4382
BSON_BINARY = 14
4483
BSON_REGEX = 15
84+
BSON_TIMESTAMP = 16
4585

4686
@staticmethod
4787
def from_value(value) -> Any:
@@ -77,41 +117,38 @@ def from_value(value) -> Any:
77117

78118

79119
# Maps BSON wire map keys directly to their corresponding TypeOrder.
80-
# BSONTimestamp maps to TypeOrder.TIMESTAMP, and BSONInt32 / BSONDecimal128
81-
# map to TypeOrder.NUMBER, enabling cross-type comparisons.
120+
# BSONInt32 and BSONDecimal128 map to TypeOrder.NUMBER, enabling cross-type comparisons.
82121
_BSON_KEY_TO_TYPE_ORDER = {
83122
"__min__": TypeOrder.BSON_MIN_KEY,
84123
"__max__": TypeOrder.BSON_MAX_KEY,
85124
"__oid__": TypeOrder.BSON_OBJECT_ID,
86125
"__int__": TypeOrder.NUMBER,
87126
"__decimal128__": TypeOrder.NUMBER,
88127
"__binary__": TypeOrder.BSON_BINARY,
89-
"__request_timestamp__": TypeOrder.TIMESTAMP,
128+
"__request_timestamp__": TypeOrder.BSON_TIMESTAMP,
90129
"__regex__": TypeOrder.BSON_REGEX,
91130
}
92131

93132

94133
# NOTE: This order is defined by the backend and cannot be changed.
95-
# BSONTimestamp shares TypeOrder.TIMESTAMP with native timestamps, and
96-
# BSONInt32 / BSONDecimal128 share TypeOrder.NUMBER, enabling direct cross-type
97-
# value comparison within those categories.
98134
_TYPE_ORDER_MAP = {
99135
TypeOrder.NULL: 0,
100136
TypeOrder.BSON_MIN_KEY: 1,
101137
TypeOrder.BOOLEAN: 2,
102138
TypeOrder.NUMBER: 3,
103139
TypeOrder.TIMESTAMP: 4,
104-
TypeOrder.STRING: 5,
105-
TypeOrder.BLOB: 6,
106-
TypeOrder.BSON_BINARY: 7,
107-
TypeOrder.REF: 8,
108-
TypeOrder.BSON_OBJECT_ID: 9,
109-
TypeOrder.GEO_POINT: 10,
110-
TypeOrder.BSON_REGEX: 11,
111-
TypeOrder.ARRAY: 12,
112-
TypeOrder.VECTOR: 13,
113-
TypeOrder.OBJECT: 14,
114-
TypeOrder.BSON_MAX_KEY: 15,
140+
TypeOrder.BSON_TIMESTAMP: 5,
141+
TypeOrder.STRING: 6,
142+
TypeOrder.BLOB: 7,
143+
TypeOrder.BSON_BINARY: 8,
144+
TypeOrder.REF: 9,
145+
TypeOrder.BSON_OBJECT_ID: 10,
146+
TypeOrder.GEO_POINT: 11,
147+
TypeOrder.BSON_REGEX: 12,
148+
TypeOrder.ARRAY: 13,
149+
TypeOrder.VECTOR: 14,
150+
TypeOrder.OBJECT: 15,
151+
TypeOrder.BSON_MAX_KEY: 16,
115152
}
116153

117154

@@ -147,8 +184,9 @@ def compare(cls, left, right) -> int:
147184
# Handles int64, double, BSONInt32, and BSONDecimal128.
148185
return cls.compare_numbers(left, right)
149186
elif leftType == TypeOrder.TIMESTAMP:
150-
# Handles native Firestore timestamps and BSONTimestamp.
151187
return cls.compare_timestamps(left, right)
188+
elif leftType == TypeOrder.BSON_TIMESTAMP:
189+
return cls.compare_bson_timestamps(left, right)
152190
elif leftType == TypeOrder.STRING:
153191
return cls._compare_to(left.string_value, right.string_value)
154192
elif leftType == TypeOrder.BLOB:
@@ -219,25 +257,31 @@ def compare_bson_regexes(left, right) -> int:
219257

220258
@staticmethod
221259
def compare_timestamps(left, right) -> Any:
222-
"""Compare native Firestore timestamps and BSON timestamps."""
223260
left_pb = getattr(left, "_pb", left)
224261
right_pb = getattr(right, "_pb", right)
225262

226-
if left_pb.WhichOneof("value_type") == "map_value":
227-
l_ts = left_pb.map_value.fields["__request_timestamp__"].map_value.fields
228-
l_sec = l_ts["seconds"].integer_value if "seconds" in l_ts else 0
229-
l_inc = l_ts["increment"].integer_value if "increment" in l_ts else 0
230-
else:
231-
l_sec = left_pb.timestamp_value.seconds
232-
l_inc = left_pb.timestamp_value.nanos
263+
seconds = Order._compare_to(
264+
left_pb.timestamp_value.seconds, right_pb.timestamp_value.seconds
265+
)
266+
if seconds != 0:
267+
return seconds
233268

234-
if right_pb.WhichOneof("value_type") == "map_value":
235-
r_ts = right_pb.map_value.fields["__request_timestamp__"].map_value.fields
236-
r_sec = r_ts["seconds"].integer_value if "seconds" in r_ts else 0
237-
r_inc = r_ts["increment"].integer_value if "increment" in r_ts else 0
238-
else:
239-
r_sec = right_pb.timestamp_value.seconds
240-
r_inc = right_pb.timestamp_value.nanos
269+
return Order._compare_to(
270+
left_pb.timestamp_value.nanos, right_pb.timestamp_value.nanos
271+
)
272+
273+
@staticmethod
274+
def compare_bson_timestamps(left, right) -> Any:
275+
left_pb = getattr(left, "_pb", left)
276+
right_pb = getattr(right, "_pb", right)
277+
278+
l_ts = left_pb.map_value.fields["__request_timestamp__"].map_value.fields
279+
l_sec = l_ts["seconds"].integer_value if "seconds" in l_ts else 0
280+
l_inc = l_ts["increment"].integer_value if "increment" in l_ts else 0
281+
282+
r_ts = right_pb.map_value.fields["__request_timestamp__"].map_value.fields
283+
r_sec = r_ts["seconds"].integer_value if "seconds" in r_ts else 0
284+
r_inc = r_ts["increment"].integer_value if "increment" in r_ts else 0
241285

242286
seconds = Order._compare_to(l_sec, r_sec)
243287
if seconds != 0:
@@ -331,23 +375,11 @@ def compare_objects(left, right) -> int:
331375
@staticmethod
332376
def compare_numbers(left, right) -> int:
333377
"""Compare numeric values across int, float, BSONInt32, and BSONDecimal128."""
334-
335-
def _to_number(val):
336-
num = decode_value(val, None)
337-
to_decimal = getattr(num, "to_decimal", None)
338-
return to_decimal() if callable(to_decimal) else getattr(num, "value", num)
339-
340378
left_val = _to_number(left)
341379
right_val = _to_number(right)
342380

343-
left_nan = (
344-
left_val.is_nan() if hasattr(left_val, "is_nan") else math.isnan(left_val)
345-
)
346-
right_nan = (
347-
right_val.is_nan()
348-
if hasattr(right_val, "is_nan")
349-
else math.isnan(right_val)
350-
)
381+
left_nan = _is_nan(left_val)
382+
right_nan = _is_nan(right_val)
351383
if left_nan or right_nan:
352384
return 0 if (left_nan and right_nan) else (-1 if left_nan else 1)
353385

‎packages/google-cloud-firestore/tests/system/test_system.py‎

Lines changed: 0 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -1369,7 +1369,6 @@ def test_bson_query_ordering(client, cleanup, database):
13691369
assert results == [BSONMinKey(), BSONInt32(10), BSONMaxKey()]
13701370

13711371

1372-
13731372
@pytest.fixture(scope="module")
13741373
def query_docs(client, database):
13751374
collection_id = "qs" + UNIQUE_RESOURCE_ID

‎packages/google-cloud-firestore/tests/unit/v1/test_order.py‎

Lines changed: 11 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -254,6 +254,16 @@ def test_order_bson_type_ordering():
254254

255255
# Test timestamp comparison (native timestamp < BSON timestamp with increment)
256256
assert target.compare(ts_native, ts_bson) == -1
257+
assert target.compare(ts_bson, ts_native) == 1
258+
259+
# Test BSON timestamp comparison
260+
ts_bson2 = encode_value(BSONTimestamp(100, 2))
261+
ts_bson_later = encode_value(BSONTimestamp(101, 0))
262+
assert target.compare(ts_bson, ts_bson2) == -1
263+
assert target.compare(ts_bson2, ts_bson) == 1
264+
assert target.compare(ts_bson, ts_bson_later) == -1
265+
assert target.compare(ts_bson_later, ts_bson) == 1
266+
assert target.compare(ts_bson, ts_bson) == 0
257267

258268
# Test BSON binary > bytes
259269
assert target.compare(bytes_native, bin_b) == -1
@@ -277,7 +287,7 @@ def test_order_bson_type_ordering():
277287
"__decimal128__": TypeOrder.NUMBER,
278288
"__binary__": TypeOrder.BSON_BINARY,
279289
"__regex__": TypeOrder.BSON_REGEX,
280-
"__request_timestamp__": TypeOrder.TIMESTAMP,
290+
"__request_timestamp__": TypeOrder.BSON_TIMESTAMP,
281291
}
282292
for key, expected_order in expected_orders.items():
283293
assert _BSON_KEY_TO_TYPE_ORDER.get(key) == expected_order

0 commit comments

Comments
 (0)