Skip to content

Commit 450fe2f

Browse files
committed
fix(bigquery): keep the Arrow schema for empty results
A query that returned no rows with ARROW yielded no batches, so to_arrow() fell back to the iterator's schema, which jobs.query omits for ARROW, and returned a table without columns. jobs.query sends arrowSchema even for zero rows, but the default job stream sends nothing for them. to_arrow_iterable() now yields one empty RecordBatch if no rows arrive, like _EmptyRowIterator already does. It has the Arrow schema from the first page or the read session; otherwise the schema from jobs.getQueryResults, which the jobs.insert path has; otherwise no columns. to_arrow() and pyarrow.Table.from_batches() keep that schema.
1 parent 8ac01b9 commit 450fe2f

3 files changed

Lines changed: 193 additions & 5 deletions

File tree

‎packages/google-cloud-bigquery/google/cloud/bigquery/_arrow_query.py‎

Lines changed: 34 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -17,7 +17,7 @@
1717
from __future__ import annotations
1818

1919
import base64
20-
from typing import Any, Dict, Iterator, Optional, Tuple, TYPE_CHECKING
20+
from typing import Any, Dict, Iterator, Optional, Sequence, Tuple, TYPE_CHECKING
2121

2222
from google.cloud.bigquery import _versions_helpers
2323
from google.cloud.bigquery import enums
@@ -134,6 +134,29 @@ def _slice_batch(
134134
return batch, remaining - batch.num_rows
135135

136136

137+
def _empty_batch(
138+
pa_schema: Optional["pyarrow.Schema"], bq_schema: Sequence[Any]
139+
) -> "pyarrow.RecordBatch":
140+
"""Build a batch without rows that keeps the result's schema.
141+
142+
Use the Arrow schema BigQuery sent if there is one. The default job
143+
stream sends nothing for zero rows, so fall back to the schema from
144+
``jobs.getQueryResults``, and else to no columns.
145+
"""
146+
if pa_schema is None and bq_schema:
147+
# Imported here: _pandas_helpers imports pandas_gbq, which imports
148+
# this package's table module.
149+
from google.cloud.bigquery import _pandas_helpers
150+
151+
pa_schema = _pandas_helpers.bq_to_arrow_schema(bq_schema)
152+
if pa_schema is None:
153+
pa_schema = pyarrow.schema([])
154+
return pyarrow.RecordBatch.from_arrays(
155+
[pyarrow.array([], type=field.type) for field in pa_schema],
156+
schema=pa_schema,
157+
)
158+
159+
137160
def _read_rows_options(options: Optional[Dict[str, str]]) -> Dict[str, str]:
138161
"""Convert ``jobs.query`` ``arrowSerializationOptions`` to the
139162
``ArrowSerializationOptions`` fields that ``ReadRows`` takes."""
@@ -231,6 +254,9 @@ def download_arrow_from_job(
231254
page_token = None
232255
job_complete = False
233256
remaining = row_iterator.max_results
257+
# Results without rows still yield one empty batch, so callers get the
258+
# schema.
259+
yielded = False
234260

235261
# Step 1: Process initial inline Arrow response from jobs.query if available.
236262
if row_iterator._first_page_response:
@@ -264,13 +290,16 @@ def download_arrow_from_job(
264290
)
265291
offset += batch.num_rows
266292
batch, remaining = _slice_batch(batch, remaining)
293+
yielded = True
267294
yield batch
268295

269296
# Step 2: Return early if all results were delivered in the first page
270297
# response, or if the first page already reached max_results. Only a
271298
# pageToken says rows remain: totalRows is 0 when cached results are too
272299
# large to return inline.
273300
if (job_complete and page_token is None) or remaining == 0:
301+
if not yielded:
302+
yield _empty_batch(pa_schema, row_iterator._schema)
274303
return
275304

276305
# Step 3: Stream remaining Arrow record batches from the job default stream,
@@ -293,6 +322,7 @@ def download_arrow_from_job(
293322
reader = _open_job_stream(row_iterator, bqstorage_client, offset, timeout)
294323
if reader is None:
295324
# An empty destination table has no streams.
325+
yield _empty_batch(pa_schema, row_iterator._schema)
296326
return
297327
finished = False
298328
try:
@@ -318,6 +348,7 @@ def download_arrow_from_job(
318348
pa_schema,
319349
)
320350
batch, remaining = _slice_batch(batch, remaining)
351+
yielded = True
321352
yield batch
322353
if remaining == 0:
323354
break
@@ -328,3 +359,5 @@ def download_arrow_from_job(
328359
# an error, or when the caller closes the iterator.
329360
if not finished:
330361
reader.cancel()
362+
if not yielded:
363+
yield _empty_batch(pa_schema, row_iterator._schema)

‎packages/google-cloud-bigquery/tests/system/test_arrow.py‎

Lines changed: 48 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -878,3 +878,51 @@ def test_query_and_wait_arrow_explicit_destination(
878878
f"projects/{bigquery_client.project}/datasets/{dataset_id}/tables/{table_name}"
879879
)
880880
assert kwargs["max_stream_count"] == 1
881+
882+
883+
@pytest.mark.parametrize(
884+
"priority",
885+
[None, enums.QueryPriority.INTERACTIVE],
886+
ids=["jobs_query", "jobs_insert"],
887+
)
888+
def test_query_and_wait_arrow_zero_rows_keep_the_schema(bigquery_client, priority):
889+
job_config = bigquery.QueryJobConfig(use_query_cache=False)
890+
if priority is not None:
891+
# jobs.query doesn't accept a priority, so this runs through jobs.insert.
892+
job_config.priority = priority
893+
894+
def query():
895+
return bigquery_client.query_and_wait(
896+
"SELECT x, CAST(x AS STRING) AS s "
897+
"FROM UNNEST(CAST([] AS ARRAY<INT64>)) AS x",
898+
job_config=job_config,
899+
query_results_format=enums.QueryResultsFormat.ARROW,
900+
)
901+
902+
batches = list(query().to_arrow_iterable())
903+
904+
assert len(batches) == 1
905+
for table in (pyarrow.Table.from_batches(batches), query().to_arrow()):
906+
assert table.num_rows == 0
907+
assert table.schema.names == ["x", "s"]
908+
assert table.schema.field("x").type == pyarrow.int64()
909+
assert table.schema.field("s").type == pyarrow.string()
910+
911+
912+
def test_query_and_wait_arrow_statements_without_results(bigquery_client, dataset_id):
913+
table_id = f"{bigquery_client.project}.{dataset_id}.arrow_statements"
914+
915+
for statement in (
916+
f"CREATE OR REPLACE TABLE `{table_id}` AS SELECT 1 AS x",
917+
f"UPDATE `{table_id}` SET x = 2 WHERE TRUE",
918+
):
919+
rows = bigquery_client.query_and_wait(
920+
statement, query_results_format=enums.QueryResultsFormat.ARROW
921+
)
922+
(batch,) = rows.to_arrow_iterable()
923+
assert batch.num_rows == 0
924+
925+
assert rows.num_dml_affected_rows == 1
926+
assert [
927+
row.x for row in bigquery_client.query_and_wait(f"SELECT x FROM `{table_id}`")
928+
] == [2]

‎packages/google-cloud-bigquery/tests/unit/test_query_results_format_arrow.py‎

Lines changed: 111 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -232,7 +232,11 @@ def test_download_arrow_from_job_id_constructs_stream_and_reads(self):
232232
with mock.patch("google.cloud.bigquery._arrow_query.pyarrow") as mock_pyarrow:
233233
mock_pyarrow.py_buffer = lambda x: x
234234
batches = list(iterator._download_arrow_from_job_id(timeout=5.0))
235-
self.assertEqual(batches, [])
235+
# No rows and no schema: one empty batch without columns.
236+
self.assertEqual(
237+
batches, [mock_pyarrow.RecordBatch.from_arrays.return_value]
238+
)
239+
mock_pyarrow.schema.assert_called_once_with([])
236240

237241
expected_stream_name = (
238242
"projects/test-proj/locations/US/jobs/test-job-456/streams/_default"
@@ -1471,13 +1475,116 @@ def test_download_arrow_explicit_destination_reads_a_read_session():
14711475
)
14721476

14731477

1474-
def test_download_arrow_explicit_destination_without_streams_reads_nothing():
1475-
iterator, bqstorage_client = _destination_iterator([], serialized_schema=b"")
1478+
@pytest.mark.parametrize(
1479+
("serialized_schema", "expected_names"),
1480+
[(None, ["x"]), (b"", [])],
1481+
ids=["with_schema", "without_schema"],
1482+
)
1483+
def test_download_arrow_explicit_destination_without_rows(
1484+
serialized_schema, expected_names
1485+
):
1486+
iterator, bqstorage_client = _destination_iterator(
1487+
[], serialized_schema=serialized_schema
1488+
)
1489+
1490+
(batch,) = iterator._download_arrow_from_job_id(bqstorage_client)
14761491

1477-
assert list(iterator._download_arrow_from_job_id(bqstorage_client)) == []
1492+
assert batch.num_rows == 0
1493+
assert batch.schema.names == expected_names
14781494
bqstorage_client.read_rows.assert_not_called()
14791495

14801496

1497+
def _zero_row_iterator():
1498+
"""A RowIterator whose jobs.query response has a schema and no rows."""
1499+
schema, _ = _arrow_pages()
1500+
return RowIterator(
1501+
client=mock.MagicMock(),
1502+
api_request=mock.MagicMock(),
1503+
path=None,
1504+
schema=(),
1505+
project="p",
1506+
location="US",
1507+
job_id="j",
1508+
query_results_format="ARROW",
1509+
first_page_response={
1510+
"jobComplete": True,
1511+
"totalRows": "0",
1512+
"arrowSchema": {"serializedSchema": base64.b64encode(schema).decode()},
1513+
# A response without rows carries no serialized batch.
1514+
"arrowRecordBatch": {},
1515+
},
1516+
)
1517+
1518+
1519+
def test_download_arrow_zero_rows_inline_keep_the_schema():
1520+
iterator = _zero_row_iterator()
1521+
1522+
(batch,) = iterator._download_arrow_from_job_id()
1523+
1524+
assert batch.num_rows == 0
1525+
assert batch.schema.names == ["x"]
1526+
iterator.client._get_shared_bqstorage_client.assert_not_called()
1527+
1528+
1529+
def test_to_arrow_zero_rows_keep_the_schema():
1530+
table = _zero_row_iterator().to_arrow()
1531+
1532+
assert table.num_rows == 0
1533+
assert table.schema.names == ["x"]
1534+
1535+
1536+
def test_download_arrow_zero_rows_streamed_use_the_query_results_schema():
1537+
pyarrow = pytest.importorskip("pyarrow")
1538+
from google.cloud.bigquery.schema import SchemaField
1539+
1540+
bqstorage_client = mock.MagicMock()
1541+
# The default job stream sends no responses for zero rows.
1542+
bqstorage_client.read_rows.return_value = []
1543+
iterator = RowIterator(
1544+
client=mock.MagicMock(),
1545+
api_request=mock.MagicMock(),
1546+
path=None,
1547+
schema=[SchemaField("x", "INTEGER")],
1548+
project="p",
1549+
location="US",
1550+
job_id="j",
1551+
query_results_format="ARROW",
1552+
first_page_response={"jobComplete": False},
1553+
)
1554+
1555+
(batch,) = iterator._download_arrow_from_job_id(bqstorage_client)
1556+
1557+
assert batch.num_rows == 0
1558+
assert batch.schema.names == ["x"]
1559+
assert batch.schema.field("x").type == pyarrow.int64()
1560+
1561+
1562+
def test_download_arrow_rows_yield_no_extra_empty_batch():
1563+
schema, batches = _arrow_pages(3)
1564+
iterator = RowIterator(
1565+
client=mock.MagicMock(),
1566+
api_request=mock.MagicMock(),
1567+
path=None,
1568+
schema=(),
1569+
project="p",
1570+
location="US",
1571+
job_id="j",
1572+
query_results_format="ARROW",
1573+
first_page_response={
1574+
"jobComplete": True,
1575+
"totalRows": "3",
1576+
"arrowSchema": {"serializedSchema": base64.b64encode(schema).decode()},
1577+
"arrowRecordBatch": {
1578+
"serializedRecordBatch": base64.b64encode(batches[0]).decode()
1579+
},
1580+
},
1581+
)
1582+
1583+
(batch,) = iterator._download_arrow_from_job_id()
1584+
1585+
assert batch.num_rows == 3
1586+
1587+
14811588
def test_to_arrow_iterable_reads_inline_result_twice():
14821589
# With JOB_CREATION_OPTIONAL and every row inline there's no job to
14831590
# stream from, so the first page has to stay on the iterator for the

0 commit comments

Comments
 (0)