Skip to content

Commit 8585747

Browse files
authored
feat: support RANGE in schema (#1746)
* feat: support RANGE in schema * lint * fix python 3.7 error * remove unused test method * address comments * add system test * correct range json schema * json format * change system test to adjust to upstream table * fix systest * remove insert row with range * systest * add unit test * fix mypy error * error * address comments
1 parent 132c14b commit 8585747

File tree

4 files changed

+166
-1
lines changed

4 files changed

+166
-1
lines changed

google/cloud/bigquery/__init__.py

+2
Original file line numberDiff line numberDiff line change
@@ -96,6 +96,7 @@
9696
from google.cloud.bigquery.routine import RemoteFunctionOptions
9797
from google.cloud.bigquery.schema import PolicyTagList
9898
from google.cloud.bigquery.schema import SchemaField
99+
from google.cloud.bigquery.schema import FieldElementType
99100
from google.cloud.bigquery.standard_sql import StandardSqlDataType
100101
from google.cloud.bigquery.standard_sql import StandardSqlField
101102
from google.cloud.bigquery.standard_sql import StandardSqlStructType
@@ -158,6 +159,7 @@
158159
"RemoteFunctionOptions",
159160
# Shared helpers
160161
"SchemaField",
162+
"FieldElementType",
161163
"PolicyTagList",
162164
"UDFResource",
163165
"ExternalConfig",

google/cloud/bigquery/schema.py

+72-1
Original file line numberDiff line numberDiff line change
@@ -16,7 +16,7 @@
1616

1717
import collections
1818
import enum
19-
from typing import Any, Dict, Iterable, Optional, Union
19+
from typing import Any, Dict, Iterable, Optional, Union, cast
2020

2121
from google.cloud.bigquery import standard_sql
2222
from google.cloud.bigquery.enums import StandardSqlTypeNames
@@ -66,6 +66,46 @@ class _DefaultSentinel(enum.Enum):
6666
_DEFAULT_VALUE = _DefaultSentinel.DEFAULT_VALUE
6767

6868

69+
class FieldElementType(object):
70+
"""Represents the type of a field element.
71+
72+
Args:
73+
element_type (str): The type of a field element.
74+
"""
75+
76+
def __init__(self, element_type: str):
77+
self._properties = {}
78+
self._properties["type"] = element_type.upper()
79+
80+
@property
81+
def element_type(self):
82+
return self._properties.get("type")
83+
84+
@classmethod
85+
def from_api_repr(cls, api_repr: Optional[dict]) -> Optional["FieldElementType"]:
86+
"""Factory: construct a FieldElementType given its API representation.
87+
88+
Args:
89+
api_repr (Dict[str, str]): field element type as returned from
90+
the API.
91+
92+
Returns:
93+
google.cloud.bigquery.FieldElementType:
94+
Python object, as parsed from ``api_repr``.
95+
"""
96+
if not api_repr:
97+
return None
98+
return cls(api_repr["type"].upper())
99+
100+
def to_api_repr(self) -> dict:
101+
"""Construct the API resource representation of this field element type.
102+
103+
Returns:
104+
Dict[str, str]: Field element type represented as an API resource.
105+
"""
106+
return self._properties
107+
108+
69109
class SchemaField(object):
70110
"""Describe a single field within a table schema.
71111
@@ -117,6 +157,12 @@ class SchemaField(object):
117157
- Struct or array composed with the above allowed functions, for example:
118158
119159
"[CURRENT_DATE(), DATE '2020-01-01'"]
160+
161+
range_element_type: FieldElementType, str, Optional
162+
The subtype of the RANGE, if the type of this field is RANGE. If
163+
the type is RANGE, this field is required. Possible values for the
164+
field element type of a RANGE include `DATE`, `DATETIME` and
165+
`TIMESTAMP`.
120166
"""
121167

122168
def __init__(
@@ -131,6 +177,7 @@ def __init__(
131177
precision: Union[int, _DefaultSentinel] = _DEFAULT_VALUE,
132178
scale: Union[int, _DefaultSentinel] = _DEFAULT_VALUE,
133179
max_length: Union[int, _DefaultSentinel] = _DEFAULT_VALUE,
180+
range_element_type: Union[FieldElementType, str, None] = None,
134181
):
135182
self._properties: Dict[str, Any] = {
136183
"name": name,
@@ -152,6 +199,11 @@ def __init__(
152199
self._properties["policyTags"] = (
153200
policy_tags.to_api_repr() if policy_tags is not None else None
154201
)
202+
if isinstance(range_element_type, str):
203+
self._properties["rangeElementType"] = {"type": range_element_type}
204+
if isinstance(range_element_type, FieldElementType):
205+
self._properties["rangeElementType"] = range_element_type.to_api_repr()
206+
155207
self._fields = tuple(fields)
156208

157209
@staticmethod
@@ -186,6 +238,12 @@ def from_api_repr(cls, api_repr: dict) -> "SchemaField":
186238
if policy_tags is not None and policy_tags is not _DEFAULT_VALUE:
187239
policy_tags = PolicyTagList.from_api_repr(policy_tags)
188240

241+
if api_repr.get("rangeElementType"):
242+
range_element_type = cast(dict, api_repr.get("rangeElementType"))
243+
element_type = range_element_type.get("type")
244+
else:
245+
element_type = None
246+
189247
return cls(
190248
field_type=field_type,
191249
fields=[cls.from_api_repr(f) for f in fields],
@@ -197,6 +255,7 @@ def from_api_repr(cls, api_repr: dict) -> "SchemaField":
197255
precision=cls.__get_int(api_repr, "precision"),
198256
scale=cls.__get_int(api_repr, "scale"),
199257
max_length=cls.__get_int(api_repr, "maxLength"),
258+
range_element_type=element_type,
200259
)
201260

202261
@property
@@ -252,6 +311,18 @@ def max_length(self):
252311
"""Optional[int]: Maximum length for the STRING or BYTES field."""
253312
return self._properties.get("maxLength")
254313

314+
@property
315+
def range_element_type(self):
316+
"""Optional[FieldElementType]: The subtype of the RANGE, if the
317+
type of this field is RANGE.
318+
319+
Must be set when ``type`` is `"RANGE"`. Must be one of `"DATE"`,
320+
`"DATETIME"` or `"TIMESTAMP"`.
321+
"""
322+
if self._properties.get("rangeElementType"):
323+
ret = self._properties.get("rangeElementType")
324+
return FieldElementType.from_api_repr(ret)
325+
255326
@property
256327
def fields(self):
257328
"""Optional[tuple]: Subfields contained in this field.

tests/data/schema.json

+8
Original file line numberDiff line numberDiff line change
@@ -83,6 +83,14 @@
8383
"mode" : "NULLABLE",
8484
"name" : "FavoriteNumber",
8585
"type" : "NUMERIC"
86+
},
87+
{
88+
"mode" : "NULLABLE",
89+
"name" : "TimeRange",
90+
"type" : "RANGE",
91+
"rangeElementType": {
92+
"type": "DATETIME"
93+
}
8694
}
8795
]
8896
}

tests/unit/test_schema.py

+84
Original file line numberDiff line numberDiff line change
@@ -97,6 +97,36 @@ def test_constructor_subfields(self):
9797
self.assertEqual(field.fields[0], sub_field1)
9898
self.assertEqual(field.fields[1], sub_field2)
9999

100+
def test_constructor_range(self):
101+
from google.cloud.bigquery.schema import FieldElementType
102+
103+
field = self._make_one(
104+
"test",
105+
"RANGE",
106+
mode="REQUIRED",
107+
description="Testing",
108+
range_element_type=FieldElementType("DATETIME"),
109+
)
110+
self.assertEqual(field.name, "test")
111+
self.assertEqual(field.field_type, "RANGE")
112+
self.assertEqual(field.mode, "REQUIRED")
113+
self.assertEqual(field.description, "Testing")
114+
self.assertEqual(field.range_element_type.element_type, "DATETIME")
115+
116+
def test_constructor_range_str(self):
117+
field = self._make_one(
118+
"test",
119+
"RANGE",
120+
mode="REQUIRED",
121+
description="Testing",
122+
range_element_type="DATETIME",
123+
)
124+
self.assertEqual(field.name, "test")
125+
self.assertEqual(field.field_type, "RANGE")
126+
self.assertEqual(field.mode, "REQUIRED")
127+
self.assertEqual(field.description, "Testing")
128+
self.assertEqual(field.range_element_type.element_type, "DATETIME")
129+
100130
def test_to_api_repr(self):
101131
from google.cloud.bigquery.schema import PolicyTagList
102132

@@ -160,6 +190,7 @@ def test_from_api_repr(self):
160190
self.assertEqual(field.fields[0].name, "bar")
161191
self.assertEqual(field.fields[0].field_type, "INTEGER")
162192
self.assertEqual(field.fields[0].mode, "NULLABLE")
193+
self.assertEqual(field.range_element_type, None)
163194

164195
def test_from_api_repr_policy(self):
165196
field = self._get_target_class().from_api_repr(
@@ -178,6 +209,23 @@ def test_from_api_repr_policy(self):
178209
self.assertEqual(field.fields[0].field_type, "INTEGER")
179210
self.assertEqual(field.fields[0].mode, "NULLABLE")
180211

212+
def test_from_api_repr_range(self):
213+
field = self._get_target_class().from_api_repr(
214+
{
215+
"mode": "nullable",
216+
"description": "test_range",
217+
"name": "foo",
218+
"type": "range",
219+
"rangeElementType": {"type": "DATETIME"},
220+
}
221+
)
222+
self.assertEqual(field.name, "foo")
223+
self.assertEqual(field.field_type, "RANGE")
224+
self.assertEqual(field.mode, "NULLABLE")
225+
self.assertEqual(field.description, "test_range")
226+
self.assertEqual(len(field.fields), 0)
227+
self.assertEqual(field.range_element_type.element_type, "DATETIME")
228+
181229
def test_from_api_repr_defaults(self):
182230
field = self._get_target_class().from_api_repr(
183231
{"name": "foo", "type": "record"}
@@ -192,8 +240,10 @@ def test_from_api_repr_defaults(self):
192240
# _properties.
193241
self.assertIsNone(field.description)
194242
self.assertIsNone(field.policy_tags)
243+
self.assertIsNone(field.range_element_type)
195244
self.assertNotIn("description", field._properties)
196245
self.assertNotIn("policyTags", field._properties)
246+
self.assertNotIn("rangeElementType", field._properties)
197247

198248
def test_name_property(self):
199249
name = "lemon-ness"
@@ -566,6 +616,40 @@ def test___repr__evaluable_with_policy_tags(self):
566616
assert field == evaled_field
567617

568618

619+
class TestFieldElementType(unittest.TestCase):
620+
@staticmethod
621+
def _get_target_class():
622+
from google.cloud.bigquery.schema import FieldElementType
623+
624+
return FieldElementType
625+
626+
def _make_one(self, *args):
627+
return self._get_target_class()(*args)
628+
629+
def test_constructor(self):
630+
element_type = self._make_one("DATETIME")
631+
self.assertEqual(element_type.element_type, "DATETIME")
632+
self.assertEqual(element_type._properties["type"], "DATETIME")
633+
634+
def test_to_api_repr(self):
635+
element_type = self._make_one("DATETIME")
636+
self.assertEqual(element_type.to_api_repr(), {"type": "DATETIME"})
637+
638+
def test_from_api_repr(self):
639+
api_repr = {"type": "DATETIME"}
640+
expected_element_type = self._make_one("DATETIME")
641+
self.assertEqual(
642+
expected_element_type.element_type,
643+
self._get_target_class().from_api_repr(api_repr).element_type,
644+
)
645+
646+
def test_from_api_repr_empty(self):
647+
self.assertEqual(None, self._get_target_class().from_api_repr({}))
648+
649+
def test_from_api_repr_none(self):
650+
self.assertEqual(None, self._get_target_class().from_api_repr(None))
651+
652+
569653
# TODO: dedup with the same class in test_table.py.
570654
class _SchemaBase(object):
571655
def _verify_field(self, field, r_field):

0 commit comments

Comments
 (0)