Repository navigation
fix(bigquery): keep the Arrow schema for empty results #18655
New issue
Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.
By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.
Already on GitHub? Sign in to your account
Changes from all commits
File filter
Filter by extension
Conversations
Jump to
Diff view
Diff view
There are no files selected for viewing
| Original file line number | Diff line number | Diff line change | ||||||||||||||||||
|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|
|
|
@@ -17,7 +17,7 @@ | |||||||||||||||||||
| from __future__ import annotations | ||||||||||||||||||||
|
|
||||||||||||||||||||
| import base64 | ||||||||||||||||||||
| from typing import Any, Dict, Iterator, Optional, Tuple, TYPE_CHECKING | ||||||||||||||||||||
| from typing import Any, Dict, Iterator, Optional, Sequence, Tuple, TYPE_CHECKING | ||||||||||||||||||||
|
|
||||||||||||||||||||
| from google.cloud.bigquery import _versions_helpers | ||||||||||||||||||||
| from google.cloud.bigquery import enums | ||||||||||||||||||||
|
|
@@ -134,6 +134,29 @@ def _slice_batch( | |||||||||||||||||||
| return batch, remaining - batch.num_rows | ||||||||||||||||||||
|
|
||||||||||||||||||||
|
|
||||||||||||||||||||
| def _empty_batch( | ||||||||||||||||||||
| pa_schema: Optional["pyarrow.Schema"], bq_schema: Sequence[Any] | ||||||||||||||||||||
| ) -> "pyarrow.RecordBatch": | ||||||||||||||||||||
| """Build a batch without rows that keeps the result's schema. | ||||||||||||||||||||
|
|
||||||||||||||||||||
| Use the Arrow schema BigQuery sent if there is one. The default job | ||||||||||||||||||||
| stream sends nothing for zero rows, so fall back to the schema from | ||||||||||||||||||||
| ``jobs.getQueryResults``, and else to no columns. | ||||||||||||||||||||
| """ | ||||||||||||||||||||
| if pa_schema is None and bq_schema: | ||||||||||||||||||||
| # Imported here: _pandas_helpers imports pandas_gbq, which imports | ||||||||||||||||||||
| # this package's table module. | ||||||||||||||||||||
| from google.cloud.bigquery import _pandas_helpers | ||||||||||||||||||||
|
|
||||||||||||||||||||
| pa_schema = _pandas_helpers.bq_to_arrow_schema(bq_schema) | ||||||||||||||||||||
| if pa_schema is None: | ||||||||||||||||||||
| pa_schema = pyarrow.schema([]) | ||||||||||||||||||||
| return pyarrow.RecordBatch.from_arrays( | ||||||||||||||||||||
| [pyarrow.array([], type=field.type) for field in pa_schema], | ||||||||||||||||||||
| schema=pa_schema, | ||||||||||||||||||||
| ) | ||||||||||||||||||||
|
|
||||||||||||||||||||
|
|
||||||||||||||||||||
| def _read_rows_options(options: Optional[Dict[str, str]]) -> Dict[str, str]: | ||||||||||||||||||||
| """Convert ``jobs.query`` ``arrowSerializationOptions`` to the | ||||||||||||||||||||
| ``ArrowSerializationOptions`` fields that ``ReadRows`` takes.""" | ||||||||||||||||||||
|
|
@@ -231,6 +254,9 @@ def download_arrow_from_job( | |||||||||||||||||||
| page_token = None | ||||||||||||||||||||
| job_complete = False | ||||||||||||||||||||
| remaining = row_iterator.max_results | ||||||||||||||||||||
| # Results without rows still yield one empty batch, so callers get the | ||||||||||||||||||||
| # schema. | ||||||||||||||||||||
| yielded = False | ||||||||||||||||||||
|
|
||||||||||||||||||||
| # Step 1: Process initial inline Arrow response from jobs.query if available. | ||||||||||||||||||||
| if row_iterator._first_page_response: | ||||||||||||||||||||
|
|
@@ -264,13 +290,16 @@ def download_arrow_from_job( | |||||||||||||||||||
| ) | ||||||||||||||||||||
| offset += batch.num_rows | ||||||||||||||||||||
| batch, remaining = _slice_batch(batch, remaining) | ||||||||||||||||||||
| yielded = True | ||||||||||||||||||||
| yield batch | ||||||||||||||||||||
|
|
||||||||||||||||||||
| # Step 2: Return early if all results were delivered in the first page | ||||||||||||||||||||
| # response, or if the first page already reached max_results. Only a | ||||||||||||||||||||
| # pageToken says rows remain: totalRows is 0 when cached results are too | ||||||||||||||||||||
| # large to return inline. | ||||||||||||||||||||
| if (job_complete and page_token is None) or remaining == 0: | ||||||||||||||||||||
| if not yielded: | ||||||||||||||||||||
| yield _empty_batch(pa_schema, row_iterator._schema) | ||||||||||||||||||||
| return | ||||||||||||||||||||
|
|
||||||||||||||||||||
| # Step 3: Stream remaining Arrow record batches from the job default stream, | ||||||||||||||||||||
|
|
@@ -293,6 +322,7 @@ def download_arrow_from_job( | |||||||||||||||||||
| reader = _open_job_stream(row_iterator, bqstorage_client, offset, timeout) | ||||||||||||||||||||
| if reader is None: | ||||||||||||||||||||
| # An empty destination table has no streams. | ||||||||||||||||||||
| yield _empty_batch(pa_schema, row_iterator._schema) | ||||||||||||||||||||
| return | ||||||||||||||||||||
|
Comment on lines
323
to
326
Contributor
There was a problem hiding this comment. Choose a reason for hiding this commentThe reason will be displayed to describe this comment to others. Learn more. If
Suggested change
Contributor
Author
There was a problem hiding this comment. Choose a reason for hiding this commentThe reason will be displayed to describe this comment to others. Learn more.
|
||||||||||||||||||||
| finished = False | ||||||||||||||||||||
| try: | ||||||||||||||||||||
|
|
@@ -318,6 +348,7 @@ def download_arrow_from_job( | |||||||||||||||||||
| pa_schema, | ||||||||||||||||||||
| ) | ||||||||||||||||||||
| batch, remaining = _slice_batch(batch, remaining) | ||||||||||||||||||||
| yielded = True | ||||||||||||||||||||
| yield batch | ||||||||||||||||||||
| if remaining == 0: | ||||||||||||||||||||
| break | ||||||||||||||||||||
|
|
@@ -328,3 +359,5 @@ def download_arrow_from_job( | |||||||||||||||||||
| # an error, or when the caller closes the iterator. | ||||||||||||||||||||
| if not finished: | ||||||||||||||||||||
| reader.cancel() | ||||||||||||||||||||
| if not yielded: | ||||||||||||||||||||
| yield _empty_batch(pa_schema, row_iterator._schema) | ||||||||||||||||||||
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
Is this necessary? Do we not get an arrow schema in the jobs.query response if the query completes with empty results.
Uh oh!
There was an error while loading. Please reload this page.
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
I filed b/573768143. Until this is addressed, we'll need to ensure we return an empty table when the response is empty.
I believe the empty schema case (this function) is not relevant to
jobs.querybut to the case where ReadRows is called afterjobs.insert.Correction: you're right that
jobs.queryreturns arrowSchema when the query completes, even with 0 rows, so this isn't used there. It's for empty results read from thejob's _defaultstream: ReadRows sends no messages at all for 0 rows, so there's no schema. That happens after jobs.insert with an anonymous destination, and also after jobs.query when the query doesn't finish in time and we wait. Checked all of these against BigQuery. Explicit destinations get the schema from the read session.