Skip to content

Commit cdf29d5

Browse files
author
Nikita Grover
committed
Add query_output_schema to ReadFromBigQuery for BEAM_ROW + query support
Schema cannot be auto-derived from a table when a query is used, so this adds an explicit query_output_schema param for that case. Fixes #36988
1 parent 402b41c commit cdf29d5

5 files changed

Lines changed: 197 additions & 9 deletions

File tree

sdks/python/apache_beam/io/gcp/bigquery.py

Lines changed: 23 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -2937,6 +2937,13 @@ class ReadFromBigQuery(PTransform):
29372937
PCollection with a schema and yielding Beam Rows via the option
29382938
`BEAM_ROW`. For more information on schemas, see
29392939
https://beam.apache.org/documentation/programming-guide/#what-is-a-schema)
2940+
query_output_schema: Required when output_type is 'BEAM_ROW' and a query
2941+
is specified. A BigQuery schema describing the query result columns,
2942+
since the schema cannot be auto-derived from an existing table when
2943+
using a query. Accepts the same formats as WriteToBigQuery's schema
2944+
parameter: a dict like
2945+
``{'fields': [{'name': 'col', 'type': 'STRING', 'mode': 'NULLABLE'}]}``,
2946+
a JSON string, or a TableSchema object.
29402947
"""
29412948
class Method(object):
29422949
EXPORT = 'EXPORT' # This is currently the default.
@@ -2952,10 +2959,12 @@ def __init__(
29522959
output_type=None,
29532960
timeout=None,
29542961
*args,
2962+
query_output_schema=None,
29552963
**kwargs):
29562964
self.method = method or ReadFromBigQuery.Method.EXPORT
29572965
self.use_native_datetime = use_native_datetime
29582966
self.output_type = output_type
2967+
self.query_output_schema = query_output_schema
29592968
self._args = args
29602969
self._kwargs = kwargs
29612970
if timeout is not None:
@@ -2979,9 +2988,15 @@ def __init__(
29792988

29802989
if self.output_type == 'BEAM_ROW' and self._kwargs.get('query',
29812990
None) is not None:
2982-
raise ValueError(
2983-
"Both a query and an output type of 'BEAM_ROW' were specified. "
2984-
"'BEAM_ROW' is not currently supported with queries.")
2991+
if self.query_output_schema is None:
2992+
raise ValueError(
2993+
"Both a query and an output type of 'BEAM_ROW' were specified "
2994+
"without a query_output_schema. When using a query, you must "
2995+
"provide query_output_schema so the output schema can be "
2996+
"determined without reading an existing table. The schema should "
2997+
"be a BigQuery schema dict, e.g. "
2998+
"{'fields': [{'name': 'col', 'type': 'STRING', 'mode': 'NULLABLE'}"
2999+
", ...]}, or a TableSchema object.")
29853000

29863001
self.gcs_location = gcs_location
29873002
self.bigquery_dataset_labels = {
@@ -3004,6 +3019,11 @@ def _expand_output_type(self, output_pcollection):
30043019
if self.output_type == 'PYTHON_DICT' or self.output_type is None:
30053020
return output_pcollection
30063021
elif self.output_type == 'BEAM_ROW':
3022+
if self._kwargs.get('query', None) is not None:
3023+
user_schema = bigquery_tools.get_dict_table_schema(
3024+
self.query_output_schema)
3025+
return output_pcollection | bigquery_schema_tools.convert_to_usertype(
3026+
user_schema, self._kwargs.get('selected_fields', None))
30073027
table_details = bigquery_tools.parse_table_reference(
30083028
table=self._kwargs.get("table", None),
30093029
dataset=self._kwargs.get("dataset", None),

sdks/python/apache_beam/io/gcp/bigquery_schema_tools_test.py

Lines changed: 70 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -54,6 +54,72 @@ def test_check_schema_conversions(self):
5454
'count': typing.Optional[np.int64]
5555
})
5656

57+
def test_query_schema_missing_field_in_data(self):
58+
"""Schema declares a field the row doesn't have -- fails loudly."""
59+
fields = [
60+
bigquery.TableFieldSchema(name='id', type='INTEGER', mode='NULLABLE'),
61+
bigquery.TableFieldSchema(name='name', type='STRING', mode='NULLABLE'),
62+
]
63+
schema = bigquery.TableSchema(fields=fields)
64+
usertype = bigquery_schema_tools.generate_user_type_from_bq_schema(schema)
65+
dofn = bigquery_schema_tools.BeamSchemaConversionDoFn(usertype)
66+
67+
input_dict = {'id': 42} # 'name' missing
68+
with self.assertRaisesRegex(TypeError,
69+
"missing.*required.*argument.*'name'"):
70+
list(dofn.process(input_dict))
71+
72+
def test_query_schema_extra_field_in_data(self):
73+
"""Row has a field the schema doesn't declare -- fails loudly."""
74+
fields = [
75+
bigquery.TableFieldSchema(name='id', type='INTEGER', mode='NULLABLE'),
76+
]
77+
schema = bigquery.TableSchema(fields=fields)
78+
usertype = bigquery_schema_tools.generate_user_type_from_bq_schema(schema)
79+
dofn = bigquery_schema_tools.BeamSchemaConversionDoFn(usertype)
80+
81+
input_dict = {'id': 42, 'extra_col': 'unexpected'}
82+
with self.assertRaisesRegex(TypeError,
83+
"unexpected keyword argument 'extra_col'"):
84+
list(dofn.process(input_dict))
85+
86+
def test_query_schema_type_mismatch_not_validated(self):
87+
"""Schema says INTEGER, data is a non-numeric string.
88+
89+
This does NOT raise -- the mismatched value passes through unvalidated.
90+
This test documents that behavior; it is a known limitation, not a
91+
guarantee that this is desirable.
92+
"""
93+
fields = [
94+
bigquery.TableFieldSchema(name='id', type='INTEGER', mode='NULLABLE'),
95+
]
96+
schema = bigquery.TableSchema(fields=fields)
97+
usertype = bigquery_schema_tools.generate_user_type_from_bq_schema(schema)
98+
dofn = bigquery_schema_tools.BeamSchemaConversionDoFn(usertype)
99+
100+
input_dict = {'id': 'not_a_number'}
101+
results = list(dofn.process(input_dict))
102+
self.assertEqual(len(results), 1)
103+
# Type is NOT coerced or validated -- the string passes through as-is.
104+
self.assertEqual(results[0].id, 'not_a_number')
105+
106+
def test_query_schema_happy_path_no_mocks(self):
107+
"""No-mock happy path: real schema, real conversion, fake row only."""
108+
fields = [
109+
bigquery.TableFieldSchema(name='id', type='INTEGER', mode='NULLABLE'),
110+
bigquery.TableFieldSchema(name='name', type='STRING', mode='NULLABLE'),
111+
]
112+
schema = bigquery.TableSchema(fields=fields)
113+
usertype = bigquery_schema_tools.generate_user_type_from_bq_schema(schema)
114+
dofn = bigquery_schema_tools.BeamSchemaConversionDoFn(usertype)
115+
116+
input_dict = {'id': 42, 'name': 'beam'}
117+
results = list(dofn.process(input_dict))
118+
119+
self.assertEqual(len(results), 1)
120+
self.assertEqual(results[0].id, 42)
121+
self.assertEqual(results[0].name, 'beam')
122+
57123
def test_check_conversion_with_selected_fields(self):
58124
fields = [
59125
bigquery.TableFieldSchema(name='stn', type='STRING', mode="NULLABLE"),
@@ -189,8 +255,8 @@ def filterTable(table):
189255
def test_unsupported_query_export(self):
190256
with self.assertRaisesRegex(
191257
ValueError,
192-
"Both a query and an output type of 'BEAM_ROW' were specified. "
193-
"'BEAM_ROW' is not currently supported with queries."):
258+
"Both a query and an output type of 'BEAM_ROW' were specified "
259+
"without a query_output_schema"):
194260
p = apache_beam.Pipeline()
195261
_ = p | apache_beam.io.gcp.bigquery.ReadFromBigQuery(
196262
table="project:dataset.sample_table",
@@ -201,8 +267,8 @@ def test_unsupported_query_export(self):
201267
def test_unsupported_query_direct_read(self):
202268
with self.assertRaisesRegex(
203269
ValueError,
204-
"Both a query and an output type of 'BEAM_ROW' were specified. "
205-
"'BEAM_ROW' is not currently supported with queries."):
270+
"Both a query and an output type of 'BEAM_ROW' were specified "
271+
"without a query_output_schema"):
206272
p = apache_beam.Pipeline()
207273
_ = p | apache_beam.io.gcp.bigquery.ReadFromBigQuery(
208274
table="project:dataset.sample_table",

sdks/python/apache_beam/io/gcp/bigquery_test.py

Lines changed: 49 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -777,6 +777,55 @@ def test_read_all_lineage(self):
777777
'bigquery:project2.dataset2.table2'
778778
]))
779779

780+
def test_query_with_beam_row_requires_schema(self):
781+
with self.assertRaisesRegex(ValueError, 'query_output_schema'):
782+
ReadFromBigQuery(
783+
query='SELECT id, name FROM dataset.table', output_type='BEAM_ROW')
784+
785+
def test_query_with_beam_row_and_schema_accepted(self):
786+
schema = {
787+
'fields': [
788+
{
789+
'name': 'id', 'type': 'INTEGER', 'mode': 'NULLABLE'
790+
},
791+
{
792+
'name': 'name', 'type': 'STRING', 'mode': 'NULLABLE'
793+
},
794+
]
795+
}
796+
transform = ReadFromBigQuery(
797+
query='SELECT id, name FROM dataset.table',
798+
output_type='BEAM_ROW',
799+
query_output_schema=schema)
800+
self.assertEqual(transform.query_output_schema, schema)
801+
802+
def test_expand_output_type_uses_query_schema(self):
803+
schema = {
804+
'fields': [
805+
{
806+
'name': 'id', 'type': 'INTEGER', 'mode': 'NULLABLE'
807+
},
808+
{
809+
'name': 'name', 'type': 'STRING', 'mode': 'NULLABLE'
810+
},
811+
]
812+
}
813+
transform = ReadFromBigQuery(
814+
query='SELECT id, name FROM dataset.table',
815+
output_type='BEAM_ROW',
816+
query_output_schema=schema)
817+
818+
with mock.patch.object(bigquery_tools.BigQueryWrapper,
819+
'get_table') as mock_get_table, \
820+
mock.patch('apache_beam.io.gcp.bigquery.bigquery_schema_tools'
821+
'.convert_to_usertype') as mock_convert:
822+
mock_convert.return_value = beam.Map(lambda x: x)
823+
fake_pcoll = mock.MagicMock()
824+
transform._expand_output_type(fake_pcoll)
825+
826+
mock_get_table.assert_not_called()
827+
mock_convert.assert_called_once_with(schema, None)
828+
780829

781830
@unittest.skipIf(HttpError is None, 'GCP dependencies are not installed')
782831
class TestBigQuerySink(unittest.TestCase):

sdks/python/apache_beam/yaml/yaml_io.py

Lines changed: 12 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -101,7 +101,8 @@ def read_from_bigquery(
101101
table: Optional[str] = None,
102102
query: Optional[str] = None,
103103
row_restriction: Optional[str] = None,
104-
fields: Optional[Iterable[str]] = None):
104+
fields: Optional[Iterable[str]] = None,
105+
schema: Optional[Any] = None):
105106
"""Reads data from BigQuery.
106107
107108
Exactly one of table or query must be set.
@@ -119,18 +120,27 @@ def read_from_bigquery(
119120
specified field is a nested field, all the sub-fields in the field will be
120121
selected. The output field order is unrelated to the order of fields
121122
given here.
123+
schema (dict): Required when query is set. A BigQuery schema describing
124+
the query result columns, e.g.
125+
``{'fields': [{'name': 'col', 'type': 'STRING', 'mode': 'NULLABLE'}]}``.
126+
Not applicable when reading from a table (schema is auto-derived).
122127
"""
123128
if query is None:
124129
assert table is not None
125130
else:
126131
assert table is None and row_restriction is None and fields is None
132+
if schema is None:
133+
raise ValueError(
134+
"When using 'query' in ReadFromBigQuery YAML transform, "
135+
"'schema' is required to define the output row structure.")
127136
return ReadFromBigQuery(
128137
query=query,
129138
table=table,
130139
row_restriction=row_restriction,
131140
selected_fields=fields,
132141
method='DIRECT_READ',
133-
output_type='BEAM_ROW')
142+
output_type='BEAM_ROW',
143+
query_output_schema=schema)
134144

135145

136146
def write_to_bigquery(

sdks/python/apache_beam/yaml/yaml_io_test.py

Lines changed: 43 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -764,6 +764,49 @@ def expand(self, pcoll):
764764
]))
765765

766766

767+
class ReadFromBigQueryTest(unittest.TestCase):
768+
def test_query_without_schema_raises(self):
769+
from apache_beam.yaml.yaml_io import read_from_bigquery
770+
with self.assertRaisesRegex(ValueError, 'schema'):
771+
read_from_bigquery(query='SELECT id FROM dataset.table')
772+
773+
def test_table_without_schema_ok(self):
774+
import unittest.mock as mock
775+
776+
from apache_beam.yaml.yaml_io import read_from_bigquery
777+
with mock.patch('apache_beam.yaml.yaml_io.ReadFromBigQuery') as mock_rfbq:
778+
mock_rfbq.return_value = mock.MagicMock()
779+
read_from_bigquery(table='project:dataset.table')
780+
mock_rfbq.assert_called_once()
781+
call_kwargs = mock_rfbq.call_args[1]
782+
self.assertIsNone(call_kwargs.get('query_output_schema'))
783+
784+
def test_query_with_schema_passes_through(self):
785+
import unittest.mock as mock
786+
787+
from apache_beam.yaml.yaml_io import read_from_bigquery
788+
schema = {
789+
'fields': [
790+
{
791+
'name': 'id', 'type': 'INTEGER', 'mode': 'NULLABLE'
792+
},
793+
]
794+
}
795+
with mock.patch('apache_beam.yaml.yaml_io.ReadFromBigQuery') as mock_rfbq:
796+
mock_rfbq.return_value = mock.MagicMock()
797+
read_from_bigquery(query='SELECT id FROM dataset.table', schema=schema)
798+
call_kwargs = mock_rfbq.call_args[1]
799+
self.assertEqual(call_kwargs['query_output_schema'], schema)
800+
801+
def test_query_and_table_both_raises(self):
802+
from apache_beam.yaml.yaml_io import read_from_bigquery
803+
with self.assertRaises(AssertionError):
804+
read_from_bigquery(
805+
table='project:dataset.table',
806+
query='SELECT id FROM dataset.table',
807+
schema={'fields': []})
808+
809+
767810
if __name__ == '__main__':
768811
logging.getLogger().setLevel(logging.INFO)
769812
unittest.main()

0 commit comments

Comments
 (0)