This is an automated email from the ASF dual-hosted git repository.

damccorm pushed a commit to branch master
in repository https://gitbox.apache.org/repos/asf/beam.git


The following commit(s) were added to refs/heads/master by this push:
     new 52f6e46bede Add query_output_schema to ReadFromBigQuery for BEAM_ROW + 
query support (#39160)
52f6e46bede is described below

commit 52f6e46bede338a94cd43c3e0fc8937263995545
Author: Nikita Grover <[email protected]>
AuthorDate: Tue Aug 4 00:35:19 2026 +0530

    Add query_output_schema to ReadFromBigQuery for BEAM_ROW + query support 
(#39160)
    
    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
    
    Co-authored-by: Nikita Grover <[email protected]>
---
 sdks/python/apache_beam/io/gcp/bigquery.py         | 26 +++++++-
 .../io/gcp/bigquery_schema_tools_test.py           | 74 ++++++++++++++++++++--
 sdks/python/apache_beam/io/gcp/bigquery_test.py    | 49 ++++++++++++++
 sdks/python/apache_beam/yaml/yaml_io.py            | 14 +++-
 sdks/python/apache_beam/yaml/yaml_io_test.py       | 43 +++++++++++++
 5 files changed, 197 insertions(+), 9 deletions(-)

diff --git a/sdks/python/apache_beam/io/gcp/bigquery.py 
b/sdks/python/apache_beam/io/gcp/bigquery.py
index a2d17f12569..314effad552 100644
--- a/sdks/python/apache_beam/io/gcp/bigquery.py
+++ b/sdks/python/apache_beam/io/gcp/bigquery.py
@@ -2937,6 +2937,13 @@ class ReadFromBigQuery(PTransform):
       PCollection with a schema and yielding Beam Rows via the option
       `BEAM_ROW`. For more information on schemas, see
       
https://beam.apache.org/documentation/programming-guide/#what-is-a-schema)
+    query_output_schema: Required when output_type is 'BEAM_ROW' and a query
+      is specified. A BigQuery schema describing the query result columns,
+      since the schema cannot be auto-derived from an existing table when
+      using a query. Accepts the same formats as WriteToBigQuery's schema
+      parameter: a dict like
+      ``{'fields': [{'name': 'col', 'type': 'STRING', 'mode': 'NULLABLE'}]}``,
+      a JSON string, or a TableSchema object.
       """
   class Method(object):
     EXPORT = 'EXPORT'  #  This is currently the default.
@@ -2952,10 +2959,12 @@ class ReadFromBigQuery(PTransform):
       output_type=None,
       timeout=None,
       *args,
+      query_output_schema=None,
       **kwargs):
     self.method = method or ReadFromBigQuery.Method.EXPORT
     self.use_native_datetime = use_native_datetime
     self.output_type = output_type
+    self.query_output_schema = query_output_schema
     self._args = args
     self._kwargs = kwargs
     if timeout is not None:
@@ -2979,9 +2988,15 @@ class ReadFromBigQuery(PTransform):
 
     if self.output_type == 'BEAM_ROW' and self._kwargs.get('query',
                                                            None) is not None:
-      raise ValueError(
-          "Both a query and an output type of 'BEAM_ROW' were specified. "
-          "'BEAM_ROW' is not currently supported with queries.")
+      if self.query_output_schema is None:
+        raise ValueError(
+            "Both a query and an output type of 'BEAM_ROW' were specified "
+            "without a query_output_schema. When using a query, you must "
+            "provide query_output_schema so the output schema can be "
+            "determined without reading an existing table. The schema should "
+            "be a BigQuery schema dict, e.g. "
+            "{'fields': [{'name': 'col', 'type': 'STRING', 'mode': 'NULLABLE'}"
+            ", ...]}, or a TableSchema object.")
 
     self.gcs_location = gcs_location
     self.bigquery_dataset_labels = {
@@ -3004,6 +3019,11 @@ class ReadFromBigQuery(PTransform):
     if self.output_type == 'PYTHON_DICT' or self.output_type is None:
       return output_pcollection
     elif self.output_type == 'BEAM_ROW':
+      if self._kwargs.get('query', None) is not None:
+        user_schema = bigquery_tools.get_dict_table_schema(
+            self.query_output_schema)
+        return output_pcollection | bigquery_schema_tools.convert_to_usertype(
+            user_schema, self._kwargs.get('selected_fields', None))
       table_details = bigquery_tools.parse_table_reference(
           table=self._kwargs.get("table", None),
           dataset=self._kwargs.get("dataset", None),
diff --git a/sdks/python/apache_beam/io/gcp/bigquery_schema_tools_test.py 
b/sdks/python/apache_beam/io/gcp/bigquery_schema_tools_test.py
index 3cf641a2fb0..73cedb3a6ae 100644
--- a/sdks/python/apache_beam/io/gcp/bigquery_schema_tools_test.py
+++ b/sdks/python/apache_beam/io/gcp/bigquery_schema_tools_test.py
@@ -54,6 +54,72 @@ class TestBigQueryToSchema(unittest.TestCase):
             'count': typing.Optional[np.int64]
         })
 
+  def test_query_schema_missing_field_in_data(self):
+    """Schema declares a field the row doesn't have -- fails loudly."""
+    fields = [
+        bigquery.TableFieldSchema(name='id', type='INTEGER', mode='NULLABLE'),
+        bigquery.TableFieldSchema(name='name', type='STRING', mode='NULLABLE'),
+    ]
+    schema = bigquery.TableSchema(fields=fields)
+    usertype = bigquery_schema_tools.generate_user_type_from_bq_schema(schema)
+    dofn = bigquery_schema_tools.BeamSchemaConversionDoFn(usertype)
+
+    input_dict = {'id': 42}  # 'name' missing
+    with self.assertRaisesRegex(TypeError,
+                                "missing.*required.*argument.*'name'"):
+      list(dofn.process(input_dict))
+
+  def test_query_schema_extra_field_in_data(self):
+    """Row has a field the schema doesn't declare -- fails loudly."""
+    fields = [
+        bigquery.TableFieldSchema(name='id', type='INTEGER', mode='NULLABLE'),
+    ]
+    schema = bigquery.TableSchema(fields=fields)
+    usertype = bigquery_schema_tools.generate_user_type_from_bq_schema(schema)
+    dofn = bigquery_schema_tools.BeamSchemaConversionDoFn(usertype)
+
+    input_dict = {'id': 42, 'extra_col': 'unexpected'}
+    with self.assertRaisesRegex(TypeError,
+                                "unexpected keyword argument 'extra_col'"):
+      list(dofn.process(input_dict))
+
+  def test_query_schema_type_mismatch_not_validated(self):
+    """Schema says INTEGER, data is a non-numeric string.
+ 
+    This does NOT raise -- the mismatched value passes through unvalidated.
+    This test documents that behavior; it is a known limitation, not a
+    guarantee that this is desirable.
+    """
+    fields = [
+        bigquery.TableFieldSchema(name='id', type='INTEGER', mode='NULLABLE'),
+    ]
+    schema = bigquery.TableSchema(fields=fields)
+    usertype = bigquery_schema_tools.generate_user_type_from_bq_schema(schema)
+    dofn = bigquery_schema_tools.BeamSchemaConversionDoFn(usertype)
+
+    input_dict = {'id': 'not_a_number'}
+    results = list(dofn.process(input_dict))
+    self.assertEqual(len(results), 1)
+    # Type is NOT coerced or validated -- the string passes through as-is.
+    self.assertEqual(results[0].id, 'not_a_number')
+
+  def test_query_schema_happy_path_no_mocks(self):
+    """No-mock happy path: real schema, real conversion, fake row only."""
+    fields = [
+        bigquery.TableFieldSchema(name='id', type='INTEGER', mode='NULLABLE'),
+        bigquery.TableFieldSchema(name='name', type='STRING', mode='NULLABLE'),
+    ]
+    schema = bigquery.TableSchema(fields=fields)
+    usertype = bigquery_schema_tools.generate_user_type_from_bq_schema(schema)
+    dofn = bigquery_schema_tools.BeamSchemaConversionDoFn(usertype)
+
+    input_dict = {'id': 42, 'name': 'beam'}
+    results = list(dofn.process(input_dict))
+
+    self.assertEqual(len(results), 1)
+    self.assertEqual(results[0].id, 42)
+    self.assertEqual(results[0].name, 'beam')
+
   def test_check_conversion_with_selected_fields(self):
     fields = [
         bigquery.TableFieldSchema(name='stn', type='STRING', mode="NULLABLE"),
@@ -189,8 +255,8 @@ class TestBigQueryToSchema(unittest.TestCase):
   def test_unsupported_query_export(self):
     with self.assertRaisesRegex(
         ValueError,
-        "Both a query and an output type of 'BEAM_ROW' were specified. "
-        "'BEAM_ROW' is not currently supported with queries."):
+        "Both a query and an output type of 'BEAM_ROW' were specified "
+        "without a query_output_schema"):
       p = apache_beam.Pipeline()
       _ = p | apache_beam.io.gcp.bigquery.ReadFromBigQuery(
           table="project:dataset.sample_table",
@@ -201,8 +267,8 @@ class TestBigQueryToSchema(unittest.TestCase):
   def test_unsupported_query_direct_read(self):
     with self.assertRaisesRegex(
         ValueError,
-        "Both a query and an output type of 'BEAM_ROW' were specified. "
-        "'BEAM_ROW' is not currently supported with queries."):
+        "Both a query and an output type of 'BEAM_ROW' were specified "
+        "without a query_output_schema"):
       p = apache_beam.Pipeline()
       _ = p | apache_beam.io.gcp.bigquery.ReadFromBigQuery(
           table="project:dataset.sample_table",
diff --git a/sdks/python/apache_beam/io/gcp/bigquery_test.py 
b/sdks/python/apache_beam/io/gcp/bigquery_test.py
index 50d758c6a31..51d13d96b73 100644
--- a/sdks/python/apache_beam/io/gcp/bigquery_test.py
+++ b/sdks/python/apache_beam/io/gcp/bigquery_test.py
@@ -777,6 +777,55 @@ class TestReadFromBigQuery(unittest.TestCase):
             'bigquery:project2.dataset2.table2'
         ]))
 
+  def test_query_with_beam_row_requires_schema(self):
+    with self.assertRaisesRegex(ValueError, 'query_output_schema'):
+      ReadFromBigQuery(
+          query='SELECT id, name FROM dataset.table', output_type='BEAM_ROW')
+
+  def test_query_with_beam_row_and_schema_accepted(self):
+    schema = {
+        'fields': [
+            {
+                'name': 'id', 'type': 'INTEGER', 'mode': 'NULLABLE'
+            },
+            {
+                'name': 'name', 'type': 'STRING', 'mode': 'NULLABLE'
+            },
+        ]
+    }
+    transform = ReadFromBigQuery(
+        query='SELECT id, name FROM dataset.table',
+        output_type='BEAM_ROW',
+        query_output_schema=schema)
+    self.assertEqual(transform.query_output_schema, schema)
+
+  def test_expand_output_type_uses_query_schema(self):
+    schema = {
+        'fields': [
+            {
+                'name': 'id', 'type': 'INTEGER', 'mode': 'NULLABLE'
+            },
+            {
+                'name': 'name', 'type': 'STRING', 'mode': 'NULLABLE'
+            },
+        ]
+    }
+    transform = ReadFromBigQuery(
+        query='SELECT id, name FROM dataset.table',
+        output_type='BEAM_ROW',
+        query_output_schema=schema)
+
+    with mock.patch.object(bigquery_tools.BigQueryWrapper,
+                           'get_table') as mock_get_table, \
+         mock.patch('apache_beam.io.gcp.bigquery.bigquery_schema_tools'
+                    '.convert_to_usertype') as mock_convert:
+      mock_convert.return_value = beam.Map(lambda x: x)
+      fake_pcoll = mock.MagicMock()
+      transform._expand_output_type(fake_pcoll)
+
+    mock_get_table.assert_not_called()
+    mock_convert.assert_called_once_with(schema, None)
+
 
 @unittest.skipIf(HttpError is None, 'GCP dependencies are not installed')
 class TestBigQuerySink(unittest.TestCase):
diff --git a/sdks/python/apache_beam/yaml/yaml_io.py 
b/sdks/python/apache_beam/yaml/yaml_io.py
index bf0b0a4c6ec..b3ef18f9608 100644
--- a/sdks/python/apache_beam/yaml/yaml_io.py
+++ b/sdks/python/apache_beam/yaml/yaml_io.py
@@ -103,7 +103,8 @@ def read_from_bigquery(
     table: Optional[str] = None,
     query: Optional[str] = None,
     row_restriction: Optional[str] = None,
-    fields: Optional[Iterable[str]] = None):
+    fields: Optional[Iterable[str]] = None,
+    schema: Optional[Any] = None):
   """Reads data from BigQuery.
 
   Exactly one of table or query must be set.
@@ -121,18 +122,27 @@ def read_from_bigquery(
       specified field is a nested field, all the sub-fields in the field will 
be
       selected. The output field order is unrelated to the order of fields
       given here.
+    schema (dict): Required when query is set. A BigQuery schema describing
+      the query result columns, e.g.
+      ``{'fields': [{'name': 'col', 'type': 'STRING', 'mode': 'NULLABLE'}]}``.
+      Not applicable when reading from a table (schema is auto-derived).
   """
   if query is None:
     assert table is not None
   else:
     assert table is None and row_restriction is None and fields is None
+    if schema is None:
+      raise ValueError(
+          "When using 'query' in ReadFromBigQuery YAML transform, "
+          "'schema' is required to define the output row structure.")
   return ReadFromBigQuery(
       query=query,
       table=table,
       row_restriction=row_restriction,
       selected_fields=fields,
       method='DIRECT_READ',
-      output_type='BEAM_ROW')
+      output_type='BEAM_ROW',
+      query_output_schema=schema)
 
 
 def write_to_bigquery(
diff --git a/sdks/python/apache_beam/yaml/yaml_io_test.py 
b/sdks/python/apache_beam/yaml/yaml_io_test.py
index 250a54689f5..c3df0328f22 100644
--- a/sdks/python/apache_beam/yaml/yaml_io_test.py
+++ b/sdks/python/apache_beam/yaml/yaml_io_test.py
@@ -764,6 +764,49 @@ class YamlMatchAllTest(unittest.TestCase):
             ]))
 
 
+class ReadFromBigQueryTest(unittest.TestCase):
+  def test_query_without_schema_raises(self):
+    from apache_beam.yaml.yaml_io import read_from_bigquery
+    with self.assertRaisesRegex(ValueError, 'schema'):
+      read_from_bigquery(query='SELECT id FROM dataset.table')
+
+  def test_table_without_schema_ok(self):
+    import unittest.mock as mock
+
+    from apache_beam.yaml.yaml_io import read_from_bigquery
+    with mock.patch('apache_beam.yaml.yaml_io.ReadFromBigQuery') as mock_rfbq:
+      mock_rfbq.return_value = mock.MagicMock()
+      read_from_bigquery(table='project:dataset.table')
+      mock_rfbq.assert_called_once()
+      call_kwargs = mock_rfbq.call_args[1]
+      self.assertIsNone(call_kwargs.get('query_output_schema'))
+
+  def test_query_with_schema_passes_through(self):
+    import unittest.mock as mock
+
+    from apache_beam.yaml.yaml_io import read_from_bigquery
+    schema = {
+        'fields': [
+            {
+                'name': 'id', 'type': 'INTEGER', 'mode': 'NULLABLE'
+            },
+        ]
+    }
+    with mock.patch('apache_beam.yaml.yaml_io.ReadFromBigQuery') as mock_rfbq:
+      mock_rfbq.return_value = mock.MagicMock()
+      read_from_bigquery(query='SELECT id FROM dataset.table', schema=schema)
+      call_kwargs = mock_rfbq.call_args[1]
+      self.assertEqual(call_kwargs['query_output_schema'], schema)
+
+  def test_query_and_table_both_raises(self):
+    from apache_beam.yaml.yaml_io import read_from_bigquery
+    with self.assertRaises(AssertionError):
+      read_from_bigquery(
+          table='project:dataset.table',
+          query='SELECT id FROM dataset.table',
+          schema={'fields': []})
+
+
 if __name__ == '__main__':
   logging.getLogger().setLevel(logging.INFO)
   unittest.main()

Reply via email to