From f4c9b6a4462614cad69ff928f2b4d1655a3ebb02 Mon Sep 17 00:00:00 2001 From: Linchin Date: Tue, 30 Dec 2025 22:38:27 +0000 Subject: [PATCH 1/6] feat: support load table with picosecond timestamp --- google/cloud/bigquery/job/load.py | 31 ++++++++++++++++++++++++++++++ tests/unit/job/test_load_config.py | 26 +++++++++++++++++++++++++ 2 files changed, 57 insertions(+) diff --git a/google/cloud/bigquery/job/load.py b/google/cloud/bigquery/job/load.py index 8cdb779ac..e78476970 100644 --- a/google/cloud/bigquery/job/load.py +++ b/google/cloud/bigquery/job/load.py @@ -760,6 +760,37 @@ def column_name_character_map(self, value: Optional[str]): self._set_sub_prop("columnNameCharacterMap", value) + @property + def timestamp_target_precision(self) -> Optional[list[int]]: + """Optional[list[int]]: [Private Preview] Precisions (maximum number of + total digits in base 10) for seconds of TIMESTAMP types that are + allowed to the destination table for autodetection mode. + + Available for the formats: CSV. + + For the CSV Format, Possible values include: + None, [], or [6]: timestamp(6) for all auto detected TIMESTAMP + columns. + [6, 12]: timestamp(6) for all auto detected TIMESTAMP columns that + have less than 6 digits of subseconds. timestamp(12) for all auto + detected TIMESTAMP columns that have more than 6 digits of + subseconds. + [12]: timestamp(12) for all auto detected TIMESTAMP columns. + + The order of the elements in this array is ignored. Inputs that have + higher precision than the highest target precision in this array will + be truncated. + """ + return self._get_sub_prop("timestampTargetPrecision") + + @timestamp_target_precision.setter + def timestamp_target_precision(self, value: Optional[list[int]]): + if value is not None: + self._set_sub_prop("timestampTargetPrecision", value) + else: + self._del_sub_prop("timestampTargetPrecision") + + class LoadJob(_AsyncJob): """Asynchronous job for loading data into a table. diff --git a/tests/unit/job/test_load_config.py b/tests/unit/job/test_load_config.py index 27d3cead1..2f50b4271 100644 --- a/tests/unit/job/test_load_config.py +++ b/tests/unit/job/test_load_config.py @@ -1061,9 +1061,33 @@ def test_column_name_character_map_none(self): "parquetOptions": {"enableListInference": True}, "columnNameCharacterMap": "V2", "someNewField": "some-value", + "timestampTargetPrecision": [6, 12], } } + def test_timestamp_target_precision_missing(self): + config = self._get_target_class()() + self.assertIsNone(config.timestamp_target_precision) + + def test_timestamp_target_precision_hit(self): + timestamp_target_precision = [6, 12] + config = self._get_target_class()() + config._properties["load"]["timestampTargetPrecision"] = timestamp_target_precision + self.assertEqual(config.timestamp_target_precision, timestamp_target_precision) + + def test_timestamp_target_precision_setter(self): + timestamp_target_precision = [6, 12] + config = self._get_target_class()() + config.timestamp_target_precision = timestamp_target_precision + self.assertEqual(config._properties["load"]["timestampTargetPrecision"], timestamp_target_precision) + + def test_timestamp_target_precision_setter_w_none(self): + timestamp_target_precision = [6, 12] + config = self._get_target_class()() + config._properties["load"]["timestampTargetPrecision"] = timestamp_target_precision + config.timestamp_target_precision = None + self.assertFalse("timestampTargetPrecision" in config._properties["load"]) + def test_from_api_repr(self): from google.cloud.bigquery.job import ( CreateDisposition, @@ -1103,6 +1127,7 @@ def test_from_api_repr(self): self.assertTrue(config.parquet_options.enable_list_inference) self.assertEqual(config.column_name_character_map, ColumnNameCharacterMap.V2) self.assertEqual(config._properties["load"]["someNewField"], "some-value") + self.assertEqual(config.timestamp_target_precision, [6, 12]) def test_to_api_repr(self): from google.cloud.bigquery.job import ( @@ -1140,6 +1165,7 @@ def test_to_api_repr(self): config.parquet_options = parquet_options config.column_name_character_map = ColumnNameCharacterMap.V2 config._properties["load"]["someNewField"] = "some-value" + config.timestamp_target_precision = [6, 12] api_repr = config.to_api_repr() From 3220439e65f577791b4f571158b3461918d8568a Mon Sep 17 00:00:00 2001 From: Linchin Date: Wed, 31 Dec 2025 00:22:15 +0000 Subject: [PATCH 2/6] add tests and list_rows() support --- google/cloud/bigquery/_helpers.py | 7 +++- google/cloud/bigquery/client.py | 14 ++++++++ google/cloud/bigquery/job/load.py | 3 +- tests/data/pico.csv | 3 ++ tests/data/pico_schema.json | 8 +++++ tests/system/conftest.py | 19 +++++++++++ tests/system/test_client.py | 23 +++++++++++++ tests/system/test_list_rows.py | 20 +++++++++++ tests/unit/_helpers/test_cell_data_parser.py | 13 ++++++-- tests/unit/job/test_load_config.py | 13 ++++++-- tests/unit/test_client.py | 35 +++++++++++++++++++- 11 files changed, 149 insertions(+), 9 deletions(-) create mode 100644 tests/data/pico.csv create mode 100644 tests/data/pico_schema.json diff --git a/google/cloud/bigquery/_helpers.py b/google/cloud/bigquery/_helpers.py index c7d7705e0..0c5c07206 100644 --- a/google/cloud/bigquery/_helpers.py +++ b/google/cloud/bigquery/_helpers.py @@ -32,6 +32,8 @@ from google.cloud._helpers import _RFC3339_MICROS from google.cloud._helpers import _RFC3339_NO_FRACTION from google.cloud._helpers import _to_bytes +from google.cloud.bigquery import enums + from google.auth import credentials as ga_credentials # type: ignore from google.api_core import client_options as client_options_lib @@ -253,7 +255,10 @@ def bytes_to_py(self, value, field): return base64.standard_b64decode(_to_bytes(value)) def timestamp_to_py(self, value, field): - """Coerce 'value' to a datetime, if set or not nullable.""" + """Coerce 'value' to a datetime, if set or not nullable. If timestamp + is of picosecond precision, preserve the string format.""" + if field.timestamp_precision == enums.TimestampPrecision.PICOSECOND: + return value if _not_null(value, field): # value will be a integer in seconds, to microsecond precision, in UTC. return _datetime_from_microseconds(int(value)) diff --git a/google/cloud/bigquery/client.py b/google/cloud/bigquery/client.py index c50e7c2d7..f099f976a 100644 --- a/google/cloud/bigquery/client.py +++ b/google/cloud/bigquery/client.py @@ -4062,6 +4062,8 @@ def list_rows( page_size: Optional[int] = None, retry: retries.Retry = DEFAULT_RETRY, timeout: TimeoutType = DEFAULT_TIMEOUT, + *, + timestamp_precision: Union[enums.TimestampPrecision, int, None] = None, ) -> RowIterator: """List the rows of the table. @@ -4110,6 +4112,11 @@ def list_rows( before using ``retry``. If multiple requests are made under the hood, ``timeout`` applies to each individual request. + timestamp_precision (Optional[enums.TimestampPrecision]): + [Private Preview] If set to `enums.TimestampPrecision.PICOSECOND`, + timestamp columns of picosecond precision will be returned with + full precision. Otherwise, will truncate to microsecond + precision. Returns: google.cloud.bigquery.table.RowIterator: @@ -4144,6 +4151,13 @@ def list_rows( params["startIndex"] = start_index params["formatOptions.useInt64Timestamp"] = True + + if timestamp_precision == enums.TimestampPrecision.PICOSECOND: + # Cannot specify both use_int64_timestamp and timestamp_output_format. + del params["formatOptions.useInt64Timestamp"] + + params["formatOptions.timestampOutputFormat"] = "ISO8601_STRING" + row_iterator = RowIterator( client=self, api_request=functools.partial(self._call_api, retry, timeout=timeout), diff --git a/google/cloud/bigquery/job/load.py b/google/cloud/bigquery/job/load.py index e78476970..a66a3ed63 100644 --- a/google/cloud/bigquery/job/load.py +++ b/google/cloud/bigquery/job/load.py @@ -759,7 +759,6 @@ def column_name_character_map(self, value: Optional[str]): value = ColumnNameCharacterMap.COLUMN_NAME_CHARACTER_MAP_UNSPECIFIED self._set_sub_prop("columnNameCharacterMap", value) - @property def timestamp_target_precision(self) -> Optional[list[int]]: """Optional[list[int]]: [Private Preview] Precisions (maximum number of @@ -776,7 +775,7 @@ def timestamp_target_precision(self) -> Optional[list[int]]: detected TIMESTAMP columns that have more than 6 digits of subseconds. [12]: timestamp(12) for all auto detected TIMESTAMP columns. - + The order of the elements in this array is ignored. Inputs that have higher precision than the highest target precision in this array will be truncated. diff --git a/tests/data/pico.csv b/tests/data/pico.csv new file mode 100644 index 000000000..bcc853040 --- /dev/null +++ b/tests/data/pico.csv @@ -0,0 +1,3 @@ +2025-01-01T00:00:00.123456789012Z +2025-01-02T00:00:00.123456789012Z +2025-01-03T00:00:00.123456789012Z \ No newline at end of file diff --git a/tests/data/pico_schema.json b/tests/data/pico_schema.json new file mode 100644 index 000000000..8227917ea --- /dev/null +++ b/tests/data/pico_schema.json @@ -0,0 +1,8 @@ +[ + { + "name": "pico_col", + "type": "TIMESTAMP", + "mode": "NULLABLE", + "timestampPrecision": "12" + } +] diff --git a/tests/system/conftest.py b/tests/system/conftest.py index 8efa042af..123aeb6e7 100644 --- a/tests/system/conftest.py +++ b/tests/system/conftest.py @@ -98,12 +98,14 @@ def load_scalars_table( data_path: str = "scalars.jsonl", source_format=enums.SourceFormat.NEWLINE_DELIMITED_JSON, schema_source="scalars_schema.json", + timestamp_target_precision=None, ) -> str: schema = bigquery_client.schema_from_json(DATA_DIR / schema_source) table_id = data_path.replace(".", "_") + hex(random.randrange(1000000)) job_config = bigquery.LoadJobConfig() job_config.schema = schema job_config.source_format = source_format + job_config.timestamp_target_precision = timestamp_target_precision full_table_id = f"{project_id}.{dataset_id}.{table_id}" with open(DATA_DIR / data_path, "rb") as data_file: job = bigquery_client.load_table_from_file( @@ -169,6 +171,23 @@ def scalars_table_csv( bigquery_client.delete_table(full_table_id, not_found_ok=True) +@pytest.fixture(scope="session") +def scalars_table_pico( + bigquery_client: bigquery.Client, project_id: str, dataset_id: str +): + full_table_id = load_scalars_table( + bigquery_client, + project_id, + dataset_id, + data_path="pico.csv", + source_format=enums.SourceFormat.CSV, + schema_source="pico_schema.json", + timestamp_target_precision=[12], + ) + yield full_table_id + bigquery_client.delete_table(full_table_id, not_found_ok=True) + + @pytest.fixture def test_table_name(request, replace_non_anum=re.compile(r"[^a-zA-Z0-9_]").sub): return replace_non_anum("_", request.node.name) diff --git a/tests/system/test_client.py b/tests/system/test_client.py index 3d32a3634..7e773598e 100644 --- a/tests/system/test_client.py +++ b/tests/system/test_client.py @@ -1295,6 +1295,29 @@ def test_load_table_from_json_schema_autodetect_table_exists(self): self.assertEqual(tuple(table.schema), table_schema) self.assertEqual(table.num_rows, 2) + def test_load_table_from_csv_w_picosecond_timestamp(self): + dataset_id = _make_dataset_id("bq_system_test") + self.temp_dataset(dataset_id) + table_id = "{}.{}.load_table_from_json_basic_use".format( + Config.CLIENT.project, dataset_id + ) + + table_schema = Config.CLIENT.schema_from_json(DATA_PATH / "pico_schema.json") + # create the table before loading so that the column order is predictable + table = helpers.retry_403(Config.CLIENT.create_table)( + Table(table_id, schema=table_schema) + ) + self.to_delete.insert(0, table) + + # do not pass an explicit job config to trigger automatic schema detection + with open(DATA_PATH / "pico.csv", "rb") as f: + load_job = Config.CLIENT.load_table_from_file(f, table_id) + load_job.result() + + table = Config.CLIENT.get_table(table) + self.assertEqual(list(table.schema), table_schema) + self.assertEqual(table.num_rows, 3) + def test_load_avro_from_uri_then_dump_table(self): from google.cloud.bigquery.job import CreateDisposition from google.cloud.bigquery.job import SourceFormat diff --git a/tests/system/test_list_rows.py b/tests/system/test_list_rows.py index 108b842ce..02b07744b 100644 --- a/tests/system/test_list_rows.py +++ b/tests/system/test_list_rows.py @@ -132,3 +132,23 @@ def test_list_rows_range(bigquery_client: bigquery.Client, scalars_table_csv: st row_null = rows[1] assert row_null["range_date"] is None + + +def test_list_rows_pico(bigquery_client: bigquery.Client, scalars_table_pico: str): + rows = bigquery_client.list_rows( + scalars_table_pico, timestamp_precision=enums.TimestampPrecision.PICOSECOND + ) + rows = list(rows) + row = rows[0] + assert row["pico_col"] == "2025-01-01T00:00:00.123456789012Z" + + +def test_list_rows_pico_truncate( + bigquery_client: bigquery.Client, scalars_table_pico: str +): + # For a picosecond timestamp column, if the user does not explicitly set + # timestamp_precision, will return truncated microsecond precision. + rows = bigquery_client.list_rows(scalars_table_pico) + rows = list(rows) + row = rows[0] + assert row["pico_col"] == "1735689600123456" diff --git a/tests/unit/_helpers/test_cell_data_parser.py b/tests/unit/_helpers/test_cell_data_parser.py index 14721a26c..f75e63b48 100644 --- a/tests/unit/_helpers/test_cell_data_parser.py +++ b/tests/unit/_helpers/test_cell_data_parser.py @@ -290,17 +290,26 @@ def test_bytes_to_py_w_base64_encoded_text(object_under_test): def test_timestamp_to_py_w_string_int_value(object_under_test): from google.cloud._helpers import _EPOCH - coerced = object_under_test.timestamp_to_py("1234567", object()) + coerced = object_under_test.timestamp_to_py("1234567", create_field()) assert coerced == _EPOCH + datetime.timedelta(seconds=1, microseconds=234567) def test_timestamp_to_py_w_int_value(object_under_test): from google.cloud._helpers import _EPOCH - coerced = object_under_test.timestamp_to_py(1234567, object()) + coerced = object_under_test.timestamp_to_py(1234567, create_field()) assert coerced == _EPOCH + datetime.timedelta(seconds=1, microseconds=234567) +def test_timestamp_to_py_w_picosecond_precision(object_under_test): + from google.cloud.bigquery import enums + + pico_schema = create_field(timestamp_precision=enums.TimestampPrecision.PICOSECOND) + pico_timestamp = "2025-01-01T00:00:00.123456789012Z" + coerced = object_under_test.timestamp_to_py(pico_timestamp, pico_schema) + assert coerced == pico_timestamp + + def test_datetime_to_py_w_string_value(object_under_test): coerced = object_under_test.datetime_to_py("2016-12-02T18:51:33", object()) assert coerced == datetime.datetime(2016, 12, 2, 18, 51, 33) diff --git a/tests/unit/job/test_load_config.py b/tests/unit/job/test_load_config.py index 2f50b4271..2e046bfbf 100644 --- a/tests/unit/job/test_load_config.py +++ b/tests/unit/job/test_load_config.py @@ -1072,19 +1072,26 @@ def test_timestamp_target_precision_missing(self): def test_timestamp_target_precision_hit(self): timestamp_target_precision = [6, 12] config = self._get_target_class()() - config._properties["load"]["timestampTargetPrecision"] = timestamp_target_precision + config._properties["load"][ + "timestampTargetPrecision" + ] = timestamp_target_precision self.assertEqual(config.timestamp_target_precision, timestamp_target_precision) def test_timestamp_target_precision_setter(self): timestamp_target_precision = [6, 12] config = self._get_target_class()() config.timestamp_target_precision = timestamp_target_precision - self.assertEqual(config._properties["load"]["timestampTargetPrecision"], timestamp_target_precision) + self.assertEqual( + config._properties["load"]["timestampTargetPrecision"], + timestamp_target_precision, + ) def test_timestamp_target_precision_setter_w_none(self): timestamp_target_precision = [6, 12] config = self._get_target_class()() - config._properties["load"]["timestampTargetPrecision"] = timestamp_target_precision + config._properties["load"][ + "timestampTargetPrecision" + ] = timestamp_target_precision config.timestamp_target_precision = None self.assertFalse("timestampTargetPrecision" in config._properties["load"]) diff --git a/tests/unit/test_client.py b/tests/unit/test_client.py index 213f382dc..e96eaff04 100644 --- a/tests/unit/test_client.py +++ b/tests/unit/test_client.py @@ -55,7 +55,7 @@ from google.cloud import bigquery from google.cloud.bigquery.dataset import DatasetReference, Dataset -from google.cloud.bigquery.enums import UpdateMode, DatasetView +from google.cloud.bigquery.enums import UpdateMode, DatasetView, TimestampPrecision from google.cloud.bigquery import exceptions from google.cloud.bigquery import ParquetOptions import google.cloud.bigquery.retry @@ -6817,6 +6817,39 @@ def test_list_rows(self): timeout=7.5, ) + def test_list_rows_pico_timestamp(self): + from google.cloud.bigquery.schema import SchemaField + from google.cloud.bigquery.table import Table + + PATH = "projects/%s/datasets/%s/tables/%s/data" % ( + self.PROJECT, + self.DS_ID, + self.TABLE_ID, + ) + creds = _make_credentials() + http = object() + client = self._make_one(project=self.PROJECT, credentials=creds, _http=http) + conn = client._connection = make_connection({}, {}) + pico_col = SchemaField( + "full_name", + "TIMESTAMP", + mode="REQUIRED", + timestamp_precision=TimestampPrecision.PICOSECOND, + ) + table = Table(self.TABLE_REF, schema=[pico_col]) + + iterator = client.list_rows( + table, timestamp_precision=TimestampPrecision.PICOSECOND + ) + next(iterator.pages) + + conn.api_request.assert_called_once_with( + method="GET", + path="/%s" % PATH, + query_params={"formatOptions.timestampOutputFormat": "ISO8601_STRING"}, + timeout=None, + ) + def test_list_rows_w_start_index_w_page_size(self): from google.cloud.bigquery.schema import SchemaField from google.cloud.bigquery.table import Table From 4281ef5b0698589c84e39e2460c432709c352893 Mon Sep 17 00:00:00 2001 From: Linchin Date: Wed, 31 Dec 2025 00:29:46 +0000 Subject: [PATCH 3/6] docstring --- google/cloud/bigquery/client.py | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/google/cloud/bigquery/client.py b/google/cloud/bigquery/client.py index f099f976a..9868882cf 100644 --- a/google/cloud/bigquery/client.py +++ b/google/cloud/bigquery/client.py @@ -4063,7 +4063,7 @@ def list_rows( retry: retries.Retry = DEFAULT_RETRY, timeout: TimeoutType = DEFAULT_TIMEOUT, *, - timestamp_precision: Union[enums.TimestampPrecision, int, None] = None, + timestamp_precision: Optional[enums.TimestampPrecision] = None, ) -> RowIterator: """List the rows of the table. From d786b3f1b8742ff412282eb25c6e3365f4d8b59d Mon Sep 17 00:00:00 2001 From: Linchin Date: Wed, 31 Dec 2025 01:08:07 +0000 Subject: [PATCH 4/6] lint --- google/cloud/bigquery/job/load.py | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/google/cloud/bigquery/job/load.py b/google/cloud/bigquery/job/load.py index a66a3ed63..9c74f7124 100644 --- a/google/cloud/bigquery/job/load.py +++ b/google/cloud/bigquery/job/load.py @@ -760,7 +760,7 @@ def column_name_character_map(self, value: Optional[str]): self._set_sub_prop("columnNameCharacterMap", value) @property - def timestamp_target_precision(self) -> Optional[list[int]]: + def timestamp_target_precision(self) -> Optional[List[int]]: """Optional[list[int]]: [Private Preview] Precisions (maximum number of total digits in base 10) for seconds of TIMESTAMP types that are allowed to the destination table for autodetection mode. @@ -783,7 +783,7 @@ def timestamp_target_precision(self) -> Optional[list[int]]: return self._get_sub_prop("timestampTargetPrecision") @timestamp_target_precision.setter - def timestamp_target_precision(self, value: Optional[list[int]]): + def timestamp_target_precision(self, value: Optional[List[int]]): if value is not None: self._set_sub_prop("timestampTargetPrecision", value) else: From 48128574cb46a84f416c995908862f8b6e988eb0 Mon Sep 17 00:00:00 2001 From: Linchin Date: Wed, 31 Dec 2025 07:15:55 +0000 Subject: [PATCH 5/6] support query() --- google/cloud/bigquery/_job_helpers.py | 15 +++++++- google/cloud/bigquery/client.py | 17 +++++++++ tests/system/test_query.py | 13 +++++++ tests/unit/test__job_helpers.py | 30 ++++++++++++++++ tests/unit/test_client.py | 50 +++++++++++++++++++++++++++ 5 files changed, 124 insertions(+), 1 deletion(-) diff --git a/google/cloud/bigquery/_job_helpers.py b/google/cloud/bigquery/_job_helpers.py index 27e90246f..1706fb1ef 100644 --- a/google/cloud/bigquery/_job_helpers.py +++ b/google/cloud/bigquery/_job_helpers.py @@ -49,6 +49,7 @@ import google.api_core.exceptions as core_exceptions from google.api_core import retry as retries +from google.cloud.bigquery import enums from google.cloud.bigquery import job import google.cloud.bigquery.job.query import google.cloud.bigquery.query @@ -265,6 +266,7 @@ def _to_query_request( query: str, location: Optional[str] = None, timeout: Optional[float] = None, + timestamp_precision: Optional[enums.TimestampPrecision] = None, ) -> Dict[str, Any]: """Transform from Job resource to QueryRequest resource. @@ -290,6 +292,12 @@ def _to_query_request( request_body.setdefault("formatOptions", {}) request_body["formatOptions"]["useInt64Timestamp"] = True # type: ignore + if timestamp_precision == enums.TimestampPrecision.PICOSECOND: + # Cannot specify both use_int64_timestamp and timestamp_output_format. + del request_body["formatOptions"]["useInt64Timestamp"] + + request_body["formatOptions"]["timestampOutputFormat"] = "ISO8601_STRING" + if timeout is not None: # Subtract a buffer for context switching, network latency, etc. request_body["timeoutMs"] = max(0, int(1000 * timeout) - _TIMEOUT_BUFFER_MILLIS) @@ -370,6 +378,7 @@ def query_jobs_query( retry: retries.Retry, timeout: Optional[float], job_retry: Optional[retries.Retry], + timestamp_precision: Optional[enums.TimestampPrecision] = None, ) -> job.QueryJob: """Initiate a query using jobs.query with jobCreationMode=JOB_CREATION_REQUIRED. @@ -377,7 +386,11 @@ def query_jobs_query( """ path = _to_query_path(project) request_body = _to_query_request( - query=query, job_config=job_config, location=location, timeout=timeout + query=query, + job_config=job_config, + location=location, + timeout=timeout, + timestamp_precision=timestamp_precision, ) def do_query(): diff --git a/google/cloud/bigquery/client.py b/google/cloud/bigquery/client.py index 9868882cf..cbdcde2d5 100644 --- a/google/cloud/bigquery/client.py +++ b/google/cloud/bigquery/client.py @@ -3469,6 +3469,8 @@ def query( timeout: TimeoutType = DEFAULT_TIMEOUT, job_retry: Optional[retries.Retry] = DEFAULT_JOB_RETRY, api_method: Union[str, enums.QueryApiMethod] = enums.QueryApiMethod.INSERT, + *, + timestamp_precision: Optional[enums.TimestampPrecision] = None, ) -> job.QueryJob: """Run a SQL query. @@ -3524,6 +3526,11 @@ def query( See :class:`google.cloud.bigquery.enums.QueryApiMethod` for details on the difference between the query start methods. + timestamp_precision (Optional[enums.TimestampPrecision]): + [Private Preview] If set to `enums.TimestampPrecision.PICOSECOND`, + timestamp columns of picosecond precision will be returned with + full precision. Otherwise, will truncate to microsecond + precision. Only applies when api_method == `enums.QueryApiMethod.QUERY`. Returns: google.cloud.bigquery.job.QueryJob: A new query job instance. @@ -3543,6 +3550,15 @@ def query( "`job_id` was provided, but the 'QUERY' `api_method` was requested." ) + if ( + timestamp_precision == enums.TimestampPrecision.PICOSECOND + and api_method == enums.QueryApiMethod.INSERT + ): + raise ValueError( + "Picosecond Timestamp is only supported when `api_method " + "== enums.QueryApiMethod.QUERY`." + ) + if project is None: project = self.project @@ -3568,6 +3584,7 @@ def query( retry, timeout, job_retry, + timestamp_precision=timestamp_precision, ) elif api_method == enums.QueryApiMethod.INSERT: return _job_helpers.query_jobs_insert( diff --git a/tests/system/test_query.py b/tests/system/test_query.py index d94a117e3..b8bb06a4c 100644 --- a/tests/system/test_query.py +++ b/tests/system/test_query.py @@ -21,6 +21,7 @@ import pytest from google.cloud import bigquery +from google.cloud.bigquery import enums from google.cloud.bigquery.query import ArrayQueryParameter from google.cloud.bigquery.query import ScalarQueryParameter from google.cloud.bigquery.query import ScalarQueryParameterType @@ -546,3 +547,15 @@ def test_session(bigquery_client: bigquery.Client, query_api_method: str): assert len(rows) == 1 assert rows[0][0] == 5 + + +def test_query_picosecond(bigquery_client: bigquery.Client): + job = bigquery_client.query( + "SELECT CAST('2025-10-20' AS TIMESTAMP(12));", + api_method="QUERY", + timestamp_precision=enums.TimestampPrecision.PICOSECOND, + ) + + result = job.result() + rows = list(result) + assert rows[0][0] == "2025-10-20T00:00:00.000000000000Z" diff --git a/tests/unit/test__job_helpers.py b/tests/unit/test__job_helpers.py index 10cbefe13..19390c7ec 100644 --- a/tests/unit/test__job_helpers.py +++ b/tests/unit/test__job_helpers.py @@ -335,6 +335,7 @@ def test_query_jobs_query_defaults(): assert request["location"] == "asia-northeast1" assert request["formatOptions"]["useInt64Timestamp"] is True assert "timeoutMs" not in request + assert "timestampOutputFormat" not in request["formatOptions"] def test_query_jobs_query_sets_format_options(): @@ -400,6 +401,35 @@ def test_query_jobs_query_sets_timeout(timeout, expected_timeout): assert request["timeoutMs"] == expected_timeout +def test_query_jobs_query_picosecond(): + mock_client = mock.create_autospec(Client) + mock_retry = mock.create_autospec(retries.Retry) + mock_job_retry = mock.create_autospec(retries.Retry) + mock_client._call_api.return_value = { + "jobReference": { + "projectId": "test-project", + "jobId": "abc", + "location": "asia-northeast1", + } + } + _job_helpers.query_jobs_query( + mock_client, + "SELECT * FROM test", + None, + "asia-northeast1", + "test-project", + mock_retry, + None, + mock_job_retry, + enums.TimestampPrecision.PICOSECOND, + ) + + _, call_kwargs = mock_client._call_api.call_args + request = call_kwargs["data"] + assert "useInt64Timestamp" not in request["formatOptions"] + assert request["formatOptions"]["timestampOutputFormat"] == "ISO8601_STRING" + + def test_query_and_wait_uses_jobs_insert(): """With unsupported features, call jobs.insert instead of jobs.query.""" client = mock.create_autospec(Client) diff --git a/tests/unit/test_client.py b/tests/unit/test_client.py index e96eaff04..960063cc7 100644 --- a/tests/unit/test_client.py +++ b/tests/unit/test_client.py @@ -5214,6 +5214,56 @@ def test_query_w_query_parameters(self): }, ) + def test_query_pico_timestamp(self): + query = "select *;" + response = { + "jobReference": { + "projectId": self.PROJECT, + "location": "EU", + "jobId": "abcd", + }, + } + creds = _make_credentials() + http = object() + client = self._make_one(project=self.PROJECT, credentials=creds, _http=http) + conn = client._connection = make_connection(response) + + client.query( + query, + location="EU", + api_method="QUERY", + timestamp_precision=TimestampPrecision.PICOSECOND, + ) + + # Check that query actually starts the job. + expected_resource = { + "query": query, + "useLegacySql": False, + "location": "EU", + "formatOptions": {"timestampOutputFormat": "ISO8601_STRING"}, + "requestId": mock.ANY, + } + conn.api_request.assert_called_once_with( + method="POST", + path=f"/projects/{self.PROJECT}/queries", + data=expected_resource, + timeout=None, + ) + + def test_query_pico_timestamp_insert_error(self): + query = "select *;" + creds = _make_credentials() + http = object() + client = self._make_one(project=self.PROJECT, credentials=creds, _http=http) + + with pytest.raises(ValueError): + client.query( + query, + location="EU", + api_method="INSERT", + timestamp_precision=TimestampPrecision.PICOSECOND, + ) + def test_query_job_rpc_fail_w_random_error(self): from google.api_core.exceptions import Unknown from google.cloud.bigquery.job import QueryJob From 73156f7caa9763258c3e76faf1c65916c3cffe09 Mon Sep 17 00:00:00 2001 From: Linchin Date: Wed, 31 Dec 2025 07:19:29 +0000 Subject: [PATCH 6/6] improve unit test --- tests/unit/test_client.py | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/tests/unit/test_client.py b/tests/unit/test_client.py index 960063cc7..1c4a9badb 100644 --- a/tests/unit/test_client.py +++ b/tests/unit/test_client.py @@ -5256,7 +5256,7 @@ def test_query_pico_timestamp_insert_error(self): http = object() client = self._make_one(project=self.PROJECT, credentials=creds, _http=http) - with pytest.raises(ValueError): + with pytest.raises(ValueError, match="Picosecond Timestamp is only"): client.query( query, location="EU",