Skip to content

Commit 74e75e8

Browse files
authored
feat: support insertAll for range (#1909)
* feat: support insertAll for range * revert INTERVAL regex * lint * add unit test * lint
1 parent 0e39066 commit 74e75e8

File tree

2 files changed

+162
-4
lines changed

2 files changed

+162
-4
lines changed

google/cloud/bigquery/_helpers.py

+50-2
Original file line numberDiff line numberDiff line change
@@ -50,6 +50,7 @@
5050
r"(?P<days>-?\d+) "
5151
r"(?P<time_sign>-?)(?P<hours>\d+):(?P<minutes>\d+):(?P<seconds>\d+)\.?(?P<fraction>\d*)?$"
5252
)
53+
_RANGE_PATTERN = re.compile(r"\[.*, .*\)")
5354

5455
BIGQUERY_EMULATOR_HOST = "BIGQUERY_EMULATOR_HOST"
5556
"""Environment variable defining host for emulator."""
@@ -334,9 +335,8 @@ def _range_from_json(value, field):
334335
The parsed range object from ``value`` if the ``field`` is not
335336
null (otherwise it is :data:`None`).
336337
"""
337-
range_literal = re.compile(r"\[.*, .*\)")
338338
if _not_null(value, field):
339-
if range_literal.match(value):
339+
if _RANGE_PATTERN.match(value):
340340
start, end = value[1:-1].split(", ")
341341
start = _range_element_from_json(start, field.range_element_type)
342342
end = _range_element_from_json(end, field.range_element_type)
@@ -531,6 +531,52 @@ def _time_to_json(value):
531531
return value
532532

533533

534+
def _range_element_to_json(value, element_type=None):
535+
"""Coerce 'value' to an JSON-compatible representation."""
536+
if value is None:
537+
return None
538+
elif isinstance(value, str):
539+
if value.upper() in ("UNBOUNDED", "NULL"):
540+
return None
541+
else:
542+
# We do not enforce range element value to be valid to reduce
543+
# redundancy with backend.
544+
return value
545+
elif (
546+
element_type and element_type.element_type.upper() in _SUPPORTED_RANGE_ELEMENTS
547+
):
548+
converter = _SCALAR_VALUE_TO_JSON_ROW.get(element_type.element_type.upper())
549+
return converter(value)
550+
else:
551+
raise ValueError(
552+
f"Unsupported RANGE element type {element_type}, or "
553+
"element type is empty. Must be DATE, DATETIME, or "
554+
"TIMESTAMP"
555+
)
556+
557+
558+
def _range_field_to_json(range_element_type, value):
559+
"""Coerce 'value' to an JSON-compatible representation."""
560+
if isinstance(value, str):
561+
# string literal
562+
if _RANGE_PATTERN.match(value):
563+
start, end = value[1:-1].split(", ")
564+
else:
565+
raise ValueError(f"RANGE literal {value} has incorrect format")
566+
elif isinstance(value, dict):
567+
# dictionary
568+
start = value.get("start")
569+
end = value.get("end")
570+
else:
571+
raise ValueError(
572+
f"Unsupported type of RANGE value {value}, must be " "string or dict"
573+
)
574+
575+
start = _range_element_to_json(start, range_element_type)
576+
end = _range_element_to_json(end, range_element_type)
577+
return {"start": start, "end": end}
578+
579+
534580
# Converters used for scalar values marshalled to the BigQuery API, such as in
535581
# query parameters or the tabledata.insert API.
536582
_SCALAR_VALUE_TO_JSON_ROW = {
@@ -676,6 +722,8 @@ def _single_field_to_json(field, row_value):
676722

677723
if field.field_type == "RECORD":
678724
return _record_field_to_json(field.fields, row_value)
725+
if field.field_type == "RANGE":
726+
return _range_field_to_json(field.range_element_type, row_value)
679727

680728
return _scalar_field_to_json(field, row_value)
681729

tests/unit/test__helpers.py

+112-2
Original file line numberDiff line numberDiff line change
@@ -1049,10 +1049,22 @@ def test_w_datetime(self):
10491049
self.assertEqual(self._call_fut(when), "12:13:41")
10501050

10511051

1052-
def _make_field(field_type, mode="NULLABLE", name="testing", fields=()):
1052+
def _make_field(
1053+
field_type,
1054+
mode="NULLABLE",
1055+
name="testing",
1056+
fields=(),
1057+
range_element_type=None,
1058+
):
10531059
from google.cloud.bigquery.schema import SchemaField
10541060

1055-
return SchemaField(name=name, field_type=field_type, mode=mode, fields=fields)
1061+
return SchemaField(
1062+
name=name,
1063+
field_type=field_type,
1064+
mode=mode,
1065+
fields=fields,
1066+
range_element_type=range_element_type,
1067+
)
10561068

10571069

10581070
class Test_scalar_field_to_json(unittest.TestCase):
@@ -1251,6 +1263,98 @@ def test_w_dict_unknown_fields(self):
12511263
)
12521264

12531265

1266+
class Test_range_field_to_json(unittest.TestCase):
1267+
def _call_fut(self, field, value):
1268+
from google.cloud.bigquery._helpers import _range_field_to_json
1269+
1270+
return _range_field_to_json(field, value)
1271+
1272+
def test_w_date(self):
1273+
field = _make_field("RANGE", range_element_type="DATE")
1274+
start = datetime.date(2016, 12, 3)
1275+
original = {"start": start}
1276+
converted = self._call_fut(field.range_element_type, original)
1277+
expected = {"start": "2016-12-03", "end": None}
1278+
self.assertEqual(converted, expected)
1279+
1280+
def test_w_date_string(self):
1281+
field = _make_field("RANGE", range_element_type="DATE")
1282+
original = {"start": "2016-12-03"}
1283+
converted = self._call_fut(field.range_element_type, original)
1284+
expected = {"start": "2016-12-03", "end": None}
1285+
self.assertEqual(converted, expected)
1286+
1287+
def test_w_datetime(self):
1288+
field = _make_field("RANGE", range_element_type="DATETIME")
1289+
start = datetime.datetime(2016, 12, 3, 14, 11, 27, 123456)
1290+
original = {"start": start}
1291+
converted = self._call_fut(field.range_element_type, original)
1292+
expected = {"start": "2016-12-03T14:11:27.123456", "end": None}
1293+
self.assertEqual(converted, expected)
1294+
1295+
def test_w_datetime_string(self):
1296+
field = _make_field("RANGE", range_element_type="DATETIME")
1297+
original = {"start": "2016-12-03T14:11:27.123456"}
1298+
converted = self._call_fut(field.range_element_type, original)
1299+
expected = {"start": "2016-12-03T14:11:27.123456", "end": None}
1300+
self.assertEqual(converted, expected)
1301+
1302+
def test_w_timestamp(self):
1303+
from google.cloud._helpers import UTC
1304+
1305+
field = _make_field("RANGE", range_element_type="TIMESTAMP")
1306+
start = datetime.datetime(2016, 12, 3, 14, 11, 27, 123456, tzinfo=UTC)
1307+
original = {"start": start}
1308+
converted = self._call_fut(field.range_element_type, original)
1309+
expected = {"start": "2016-12-03T14:11:27.123456Z", "end": None}
1310+
self.assertEqual(converted, expected)
1311+
1312+
def test_w_timestamp_string(self):
1313+
field = _make_field("RANGE", range_element_type="TIMESTAMP")
1314+
original = {"start": "2016-12-03T14:11:27.123456Z"}
1315+
converted = self._call_fut(field.range_element_type, original)
1316+
expected = {"start": "2016-12-03T14:11:27.123456Z", "end": None}
1317+
self.assertEqual(converted, expected)
1318+
1319+
def test_w_timestamp_float(self):
1320+
field = _make_field("RANGE", range_element_type="TIMESTAMP")
1321+
original = {"start": 12.34567}
1322+
converted = self._call_fut(field.range_element_type, original)
1323+
expected = {"start": 12.34567, "end": None}
1324+
self.assertEqual(converted, expected)
1325+
1326+
def test_w_string_literal(self):
1327+
field = _make_field("RANGE", range_element_type="DATE")
1328+
original = "[2016-12-03, UNBOUNDED)"
1329+
converted = self._call_fut(field.range_element_type, original)
1330+
expected = {"start": "2016-12-03", "end": None}
1331+
self.assertEqual(converted, expected)
1332+
1333+
def test_w_unsupported_range_element_type(self):
1334+
field = _make_field("RANGE", range_element_type="TIME")
1335+
with self.assertRaises(ValueError):
1336+
self._call_fut(
1337+
field.range_element_type,
1338+
{"start": datetime.time(12, 13, 41)},
1339+
)
1340+
1341+
def test_w_no_range_element_type(self):
1342+
field = _make_field("RANGE")
1343+
with self.assertRaises(ValueError):
1344+
self._call_fut(field.range_element_type, "2016-12-03")
1345+
1346+
def test_w_incorrect_literal_format(self):
1347+
field = _make_field("RANGE", range_element_type="DATE")
1348+
original = "[2016-12-03, UNBOUNDED]"
1349+
with self.assertRaises(ValueError):
1350+
self._call_fut(field.range_element_type, original)
1351+
1352+
def test_w_unsupported_representation(self):
1353+
field = _make_field("RANGE", range_element_type="DATE")
1354+
with self.assertRaises(ValueError):
1355+
self._call_fut(field.range_element_type, object())
1356+
1357+
12541358
class Test_field_to_json(unittest.TestCase):
12551359
def _call_fut(self, field, value):
12561360
from google.cloud.bigquery._helpers import _field_to_json
@@ -1285,6 +1389,12 @@ def test_w_scalar(self):
12851389
converted = self._call_fut(field, original)
12861390
self.assertEqual(converted, str(original))
12871391

1392+
def test_w_range(self):
1393+
field = _make_field("RANGE", range_element_type="DATE")
1394+
original = {"start": "2016-12-03", "end": "2024-12-03"}
1395+
converted = self._call_fut(field, original)
1396+
self.assertEqual(converted, original)
1397+
12881398

12891399
class Test_snake_to_camel_case(unittest.TestCase):
12901400
def _call_fut(self, value):

0 commit comments

Comments
 (0)