Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
Show all changes
34 commits
Select commit Hold shift + click to select a range
2e1daa8
feat: support timestamp_precision in table schema
Linchin Nov 20, 2025
253ac1f
undelete test_to_api_repr_with_subfield
Linchin Nov 20, 2025
dc3c498
lint
Linchin Nov 20, 2025
97f0251
Merge branch 'main' into pico-sql
Linchin Nov 24, 2025
234a3fd
remove property setter as it is read only
Linchin Nov 24, 2025
b20159b
docstring
Linchin Nov 24, 2025
a1bc2cb
update unit test
Linchin Nov 24, 2025
bc6dcda
unit test
Linchin Nov 24, 2025
518a12c
add create_table system test
Linchin Nov 24, 2025
a8d5f5c
typo
Linchin Nov 24, 2025
0567adf
add query system test
Linchin Nov 25, 2025
1268c45
remove query system test
Linchin Nov 25, 2025
8603973
unit test
Linchin Nov 25, 2025
cb9f818
unit test
Linchin Nov 25, 2025
696dfff
unit test
Linchin Nov 25, 2025
d24df7d
docstring
Linchin Nov 25, 2025
873bff6
docstring
Linchin Nov 25, 2025
6a93c26
docstring
Linchin Nov 25, 2025
9a4f72f
improve __repr__()
Linchin Nov 25, 2025
c146e39
Merge branch 'main' into pico-sql
Linchin Nov 26, 2025
fc08533
use enum for timestamp_precision
Linchin Dec 8, 2025
0b743f3
delete file
Linchin Dec 8, 2025
2a81ef9
docstring
Linchin Dec 8, 2025
e131b6d
update test
Linchin Dec 8, 2025
7693537
improve unit test
Linchin Dec 8, 2025
5d2fbf0
fix system test
Linchin Dec 8, 2025
c7c2b47
Update tests/system/test_client.py
Linchin Dec 9, 2025
f87b618
only allow enums values
Linchin Dec 11, 2025
c0e4595
docstring and tests
Linchin Dec 11, 2025
04c5f59
handle server inconsistency and unit tests
Linchin Dec 11, 2025
255b87a
Merge branch 'main' into pico-sql
Linchin Dec 17, 2025
657dd84
Merge branch 'main' into pico-sql
Linchin Dec 17, 2025
4cd3df4
improve code
Linchin Dec 19, 2025
e6a3f8b
docstring
Linchin Dec 19, 2025
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
15 changes: 15 additions & 0 deletions google/cloud/bigquery/enums.py
Original file line number Diff line number Diff line change
Expand Up @@ -480,3 +480,18 @@ class SourceColumnMatch(str, enum.Enum):
NAME = "NAME"
"""Matches by name. This reads the header row as column names and reorders
columns to match the field names in the schema."""


class TimestampPrecision(enum.Enum):
"""Precision (maximum number of total digits in base 10) for seconds of
TIMESTAMP type."""

MICROSECOND = None
"""
Default, for TIMESTAMP type with microsecond precision.
"""

PICOSECOND = 12
"""
For TIMESTAMP type with picosecond precision.
"""
55 changes: 46 additions & 9 deletions google/cloud/bigquery/schema.py
Original file line number Diff line number Diff line change
Expand Up @@ -196,6 +196,14 @@ class SchemaField(object):

Only valid for top-level schema fields (not nested fields).
If the type is FOREIGN, this field is required.

timestamp_precision: Optional[enums.TimestampPrecision]
Precision (maximum number of total digits in base 10) for seconds
of TIMESTAMP type.

Defaults to `enums.TimestampPrecision.MICROSECOND` (`None`) for
microsecond precision. Use `enums.TimestampPrecision.PICOSECOND`
(`12`) for picosecond precision.
"""

def __init__(
Expand All @@ -213,6 +221,7 @@ def __init__(
range_element_type: Union[FieldElementType, str, None] = None,
rounding_mode: Union[enums.RoundingMode, str, None] = None,
foreign_type_definition: Optional[str] = None,
timestamp_precision: Optional[enums.TimestampPrecision] = None,
):
self._properties: Dict[str, Any] = {
"name": name,
Expand All @@ -237,6 +246,13 @@ def __init__(
if isinstance(policy_tags, PolicyTagList)
else None
)
if isinstance(timestamp_precision, enums.TimestampPrecision):
self._properties["timestampPrecision"] = timestamp_precision.value
elif timestamp_precision is not None:
raise ValueError(
"timestamp_precision must be class enums.TimestampPrecision "
f"or None, got {type(timestamp_precision)} instead."
)
if isinstance(range_element_type, str):
self._properties["rangeElementType"] = {"type": range_element_type}
if isinstance(range_element_type, FieldElementType):
Expand All @@ -254,15 +270,22 @@ def from_api_repr(cls, api_repr: dict) -> "SchemaField":
"""Return a ``SchemaField`` object deserialized from a dictionary.

Args:
api_repr (Mapping[str, str]): The serialized representation
of the SchemaField, such as what is output by
:meth:`to_api_repr`.
api_repr (dict): The serialized representation of the SchemaField,
such as what is output by :meth:`to_api_repr`.

Returns:
google.cloud.bigquery.schema.SchemaField: The ``SchemaField`` object.
"""
placeholder = cls("this_will_be_replaced", "PLACEHOLDER")

# The API would return a string despite we send an integer. To ensure
# success of resending received schema, we convert string to integer
# to ensure consistency.
Copy link
Contributor

Choose a reason for hiding this comment

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

This seems a bit surprising. The backend returns "12"? Or is this converted somewhere locally

Copy link
Contributor Author

Choose a reason for hiding this comment

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

Yes, the backend expects an integer but returns a string. I will open a bug with the BigQuery team.

Copy link
Contributor Author

Choose a reason for hiding this comment

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

Upon a second thought, this has been a persistent issue with other integer fields as well, hence the helper function would force a type conversion here:

try:
api_repr["timestampPrecision"] = int(api_repr["timestampPrecision"])
except (TypeError, KeyError):
pass

Copy link
Contributor

Choose a reason for hiding this comment

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

I was going to point out there was an error here, because the docstrings say Mapping but you're only checking against dict. But I guess the actual type annotation says dict. So there's definitely an inconsistency there, but maybe this logic is ok.

Still, I think this could be a bit cleaner as a try/catch

try:
  # ensure timestamp is in int format
  api_repr["timestampPrecision"] = int(api_repr["timestampPrecision"])
except TypeError, KeyError:
  # ignore unset timestamps
  pass

Copy link
Contributor Author

Choose a reason for hiding this comment

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

Good catch, I have updated the docstring and the code.

# Note: we don't make a copy of api_repr because this can cause
# unnecessary slowdowns, especially on deeply nested STRUCT / RECORD
# fields. See https://github.com/googleapis/python-bigquery/issues/6
Expand Down Expand Up @@ -374,6 +397,16 @@ def policy_tags(self):
resource = self._properties.get("policyTags")
return PolicyTagList.from_api_repr(resource) if resource is not None else None

@property
def timestamp_precision(self) -> enums.TimestampPrecision:
"""Precision (maximum number of total digits in base 10) for seconds of
TIMESTAMP type.

Returns:
enums.TimestampPrecision: value of TimestampPrecision.
"""
return enums.TimestampPrecision(self._properties.get("timestampPrecision"))

def to_api_repr(self) -> dict:
"""Return a dictionary representing this schema field.

Expand Down Expand Up @@ -408,6 +441,8 @@ def _key(self):
None if self.policy_tags is None else tuple(sorted(self.policy_tags.names))
)

timestamp_precision = self._properties.get("timestampPrecision")

return (
self.name,
field_type,
Expand All @@ -417,6 +452,7 @@ def _key(self):
self.description,
self.fields,
policy_tags,
timestamp_precision,
)

def to_standard_sql(self) -> standard_sql.StandardSqlField:
Expand Down Expand Up @@ -467,10 +503,9 @@ def __hash__(self):
return hash(self._key())

def __repr__(self):
key = self._key()
policy_tags = key[-1]
*initial_tags, policy_tags, timestamp_precision_tag = self._key()
policy_tags_inst = None if policy_tags is None else PolicyTagList(policy_tags)
adjusted_key = key[:-1] + (policy_tags_inst,)
adjusted_key = (*initial_tags, policy_tags_inst, timestamp_precision_tag)
return f"{self.__class__.__name__}{adjusted_key}"


Expand Down Expand Up @@ -530,9 +565,11 @@ def _to_schema_fields(schema):
if isinstance(schema, Sequence):
# Input is a Sequence (e.g. a list): Process and return a list of SchemaFields
return [
field
if isinstance(field, SchemaField)
else SchemaField.from_api_repr(field)
(
field
if isinstance(field, SchemaField)
else SchemaField.from_api_repr(field)
)
for field in schema
]

Expand Down
23 changes: 23 additions & 0 deletions tests/system/test_client.py
Original file line number Diff line number Diff line change
Expand Up @@ -74,6 +74,16 @@
bigquery.SchemaField("full_name", "STRING", mode="REQUIRED"),
bigquery.SchemaField("age", "INTEGER", mode="REQUIRED"),
]
SCHEMA_PICOSECOND = [
bigquery.SchemaField("full_name", "STRING", mode="REQUIRED"),
bigquery.SchemaField("age", "INTEGER", mode="REQUIRED"),
bigquery.SchemaField(
"time_pico",
"TIMESTAMP",
mode="REQUIRED",
timestamp_precision=enums.TimestampPrecision.PICOSECOND,
),
]
CLUSTERING_SCHEMA = [
bigquery.SchemaField("full_name", "STRING", mode="REQUIRED"),
bigquery.SchemaField("age", "INTEGER", mode="REQUIRED"),
Expand Down Expand Up @@ -631,6 +641,19 @@ def test_create_table_w_time_partitioning_w_clustering_fields(self):
self.assertEqual(time_partitioning.field, "transaction_time")
self.assertEqual(table.clustering_fields, ["user_email", "store_code"])

def test_create_table_w_picosecond_timestamp(self):
dataset = self.temp_dataset(_make_dataset_id("create_table"))
table_id = "test_table"
table_arg = Table(dataset.table(table_id), schema=SCHEMA_PICOSECOND)
self.assertFalse(_table_exists(table_arg))

table = helpers.retry_403(Config.CLIENT.create_table)(table_arg)
self.to_delete.insert(0, table)

self.assertTrue(_table_exists(table))
self.assertEqual(table.table_id, table_id)
self.assertEqual(table.schema, SCHEMA_PICOSECOND)
Copy link
Contributor

Choose a reason for hiding this comment

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

can we have a test that reads back a timestamp, and makes sure its in the expected range? Or am I misunderstanding?

Copy link
Contributor Author

Choose a reason for hiding this comment

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

This PR only involves creating and reading table schema that has picosecond timestamp. I think we can add the tests in the PR supporting writing to and reading from the table.


def test_delete_dataset_with_string(self):
dataset_id = _make_dataset_id("delete_table_true_with_string")
project = Config.CLIENT.project
Expand Down
61 changes: 60 additions & 1 deletion tests/unit/test_schema.py
Original file line number Diff line number Diff line change
Expand Up @@ -52,6 +52,9 @@ def test_constructor_defaults(self):
self.assertIsNone(field.default_value_expression)
self.assertEqual(field.rounding_mode, None)
self.assertEqual(field.foreign_type_definition, None)
self.assertEqual(
field.timestamp_precision, enums.TimestampPrecision.MICROSECOND
)

def test_constructor_explicit(self):
FIELD_DEFAULT_VALUE_EXPRESSION = "This is the default value for this field"
Expand All @@ -69,6 +72,7 @@ def test_constructor_explicit(self):
default_value_expression=FIELD_DEFAULT_VALUE_EXPRESSION,
rounding_mode=enums.RoundingMode.ROUNDING_MODE_UNSPECIFIED,
foreign_type_definition="INTEGER",
timestamp_precision=enums.TimestampPrecision.PICOSECOND,
)
self.assertEqual(field.name, "test")
self.assertEqual(field.field_type, "STRING")
Expand All @@ -87,6 +91,10 @@ def test_constructor_explicit(self):
)
self.assertEqual(field.rounding_mode, "ROUNDING_MODE_UNSPECIFIED")
self.assertEqual(field.foreign_type_definition, "INTEGER")
self.assertEqual(
field.timestamp_precision,
enums.TimestampPrecision.PICOSECOND,
)

def test_constructor_explicit_none(self):
field = self._make_one("test", "STRING", description=None, policy_tags=None)
Expand Down Expand Up @@ -189,6 +197,23 @@ def test_to_api_repr_with_subfield(self):
},
)

def test_to_api_repr_w_timestamp_precision(self):
field = self._make_one(
"foo",
"TIMESTAMP",
"NULLABLE",
timestamp_precision=enums.TimestampPrecision.PICOSECOND,
)
self.assertEqual(
field.to_api_repr(),
{
"mode": "NULLABLE",
"name": "foo",
"type": "TIMESTAMP",
"timestampPrecision": 12,
},
)

def test_from_api_repr(self):
field = self._get_target_class().from_api_repr(
{
Expand All @@ -198,6 +223,7 @@ def test_from_api_repr(self):
"name": "foo",
"type": "record",
"roundingMode": "ROUNDING_MODE_UNSPECIFIED",
"timestampPrecision": 12,
}
)
self.assertEqual(field.name, "foo")
Expand All @@ -210,6 +236,10 @@ def test_from_api_repr(self):
self.assertEqual(field.fields[0].mode, "NULLABLE")
self.assertEqual(field.range_element_type, None)
self.assertEqual(field.rounding_mode, "ROUNDING_MODE_UNSPECIFIED")
self.assertEqual(
field.timestamp_precision,
enums.TimestampPrecision.PICOSECOND,
)

def test_from_api_repr_policy(self):
field = self._get_target_class().from_api_repr(
Expand Down Expand Up @@ -264,6 +294,17 @@ def test_from_api_repr_defaults(self):
self.assertNotIn("policyTags", field._properties)
self.assertNotIn("rangeElementType", field._properties)

def test_from_api_repr_timestamp_precision_str(self):
# The backend would return timestampPrecision field as a string, even
# if we send over an integer. This test verifies we manually converted
# it into integer to ensure resending could succeed.
field = self._get_target_class().from_api_repr(
{
"timestampPrecision": "12",
}
)
self.assertEqual(field._properties["timestampPrecision"], 12)

def test_name_property(self):
name = "lemon-ness"
schema_field = self._make_one(name, "INTEGER")
Expand Down Expand Up @@ -323,6 +364,22 @@ def test_foreign_type_definition_property_str(self):
schema_field._properties["foreignTypeDefinition"] = FOREIGN_TYPE_DEFINITION
self.assertEqual(schema_field.foreign_type_definition, FOREIGN_TYPE_DEFINITION)

def test_timestamp_precision_unsupported_type(self):
with pytest.raises(ValueError) as e:
self._make_one("test", "TIMESTAMP", timestamp_precision=12)

assert "timestamp_precision must be class enums.TimestampPrecision" in str(
e.value
)

def test_timestamp_precision_property(self):
TIMESTAMP_PRECISION = enums.TimestampPrecision.PICOSECOND
schema_field = self._make_one("test", "TIMESTAMP")
schema_field._properties[
"timestampPrecision"
] = enums.TimestampPrecision.PICOSECOND.value
self.assertEqual(schema_field.timestamp_precision, TIMESTAMP_PRECISION)

def test_to_standard_sql_simple_type(self):
examples = (
# a few legacy types
Expand Down Expand Up @@ -637,7 +694,9 @@ def test___hash__not_equals(self):

def test___repr__(self):
field1 = self._make_one("field1", "STRING")
expected = "SchemaField('field1', 'STRING', 'NULLABLE', None, None, (), None)"
expected = (
"SchemaField('field1', 'STRING', 'NULLABLE', None, None, (), None, None)"
)
self.assertEqual(repr(field1), expected)

def test___repr__evaluable_no_policy_tags(self):
Expand Down