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

claudevdm 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 01559cc9127 Added schema_update_options to Python BigQuery writes 
(#39078)
01559cc9127 is described below

commit 01559cc91277fdcbcd45080ffe68a620790f8283
Author: Lalit Yadav <[email protected]>
AuthorDate: Thu Aug 27 08:39:34 2026 -0500

    Added schema_update_options to Python BigQuery writes (#39078)
    
    * Add schema_update_options to Python BigQuery writes
    
    * Fix BigQuery schema update options formatting
    
    * Validate BigQuery schema update options
    
    * updated CHANGES.md
    
    * changing the BigQuerySchemaUpdateOption to Enum
    
    ---------
    
    Co-authored-by: claudevdm <[email protected]>
---
 CHANGES.md                                      |   1 +
 sdks/python/apache_beam/io/gcp/bigquery.py      |  90 ++++++++++++++-
 sdks/python/apache_beam/io/gcp/bigquery_test.py | 144 +++++++++++++++++++++++-
 3 files changed, 232 insertions(+), 3 deletions(-)

diff --git a/CHANGES.md b/CHANGES.md
index 903f65b50a6..16c2d3ee6dd 100644
--- a/CHANGES.md
+++ b/CHANGES.md
@@ -65,6 +65,7 @@
 ## I/Os
 
 * Support for X source added (Java/Python) 
([#X](https://github.com/apache/beam/issues/X)).
+* Added `schema_update_options` to `WriteToBigQuery` file loads, allowing 
BigQuery load jobs to add nullable fields or relax required fields when 
appending data (Python) ([#21141](https://github.com/apache/beam/issues/21141)).
 * BigQueryIO now supports reading BigQuery Lakehouse runtime catalog (BigLake 
metastore) Iceberg tables with the Storage Read API, using 4-part 
`project.catalog.namespace.table` identifiers (or a `TableReference` with a 
composite `catalog.namespace` dataset id). Previously such references were 
silently mis-parsed (Java) 
([#39597](https://github.com/apache/beam/issues/39597)) .
 
 ## New Features / Improvements
diff --git a/sdks/python/apache_beam/io/gcp/bigquery.py 
b/sdks/python/apache_beam/io/gcp/bigquery.py
index 314effad552..40f17bfe9b0 100644
--- a/sdks/python/apache_beam/io/gcp/bigquery.py
+++ b/sdks/python/apache_beam/io/gcp/bigquery.py
@@ -366,6 +366,7 @@ import time
 import uuid
 import warnings
 from dataclasses import dataclass
+from enum import Enum
 from typing import Optional
 from typing import Union
 
@@ -442,6 +443,7 @@ except ImportError:
 __all__ = [
     'TableRowJsonCoder',
     'BigQueryDisposition',
+    'BigQuerySchemaUpdateOption',
     'BigQuerySource',
     'BigQuerySink',
     'BigQueryQueryPriority',
@@ -483,6 +485,35 @@ overhead: 
https://cloud.google.com/bigquery/quotas#streaming_inserts
 """
 MAX_INSERT_PAYLOAD_SIZE = 9 << 20
 
+_SCHEMA_UPDATE_OPTIONS = 'schemaUpdateOptions'
+
+
+def _merge_schema_update_options(
+    additional_bq_parameters, schema_update_options):
+  additional_bq_parameters = dict(additional_bq_parameters or {})
+  if _SCHEMA_UPDATE_OPTIONS in additional_bq_parameters:
+    raise ValueError(
+        '%s can be set with either schema_update_options or '
+        'additional_bq_parameters, but not both.' % _SCHEMA_UPDATE_OPTIONS)
+  additional_bq_parameters[_SCHEMA_UPDATE_OPTIONS] = schema_update_options
+  return additional_bq_parameters
+
+
+class _AdditionalBQParametersWithSchemaUpdateOptions(object):
+  def __init__(self, additional_bq_parameters, schema_update_options):
+    self.additional_bq_parameters = additional_bq_parameters
+    self.schema_update_options = schema_update_options
+
+  def __call__(self, destination):
+    if callable(self.additional_bq_parameters):
+      additional_bq_parameters = self.additional_bq_parameters(destination)
+    elif isinstance(self.additional_bq_parameters, vp.ValueProvider):
+      additional_bq_parameters = self.additional_bq_parameters.get()
+    else:
+      additional_bq_parameters = self.additional_bq_parameters
+    return _merge_schema_update_options(
+        additional_bq_parameters, self.schema_update_options)
+
 
 @deprecated(since='2.11.0', current="bigquery_tools.parse_table_reference")
 def _parse_table_reference(table, dataset=None, project=None):
@@ -580,6 +611,32 @@ class BigQueryDisposition(object):
     return disposition
 
 
+class BigQuerySchemaUpdateOption(str, Enum):
+  """Enum holding standard strings used for schema update options."""
+
+  ALLOW_FIELD_ADDITION = 'ALLOW_FIELD_ADDITION'
+  ALLOW_FIELD_RELAXATION = 'ALLOW_FIELD_RELAXATION'
+
+  @staticmethod
+  def validate(options):
+    if options is None:
+      return None
+    if not isinstance(options, list):
+      raise ValueError(
+          'schema_update_options must be a list. Received %s.' %
+          type(options).__name__)
+    values = tuple(option.value for option in BigQuerySchemaUpdateOption)
+    validated_options = []
+    for option in options:
+      try:
+        validated_options.append(BigQuerySchemaUpdateOption(option).value)
+      except ValueError:
+        raise ValueError(
+            'Invalid schema update option %s. Expecting %s' %
+            (option, values)) from None
+    return validated_options
+
+
 class BigQueryQueryPriority(object):
   """Class holding standard strings used for query priority."""
 
@@ -2007,7 +2064,8 @@ class WriteToBigQuery(PTransform):
       primary_key: list[str] = None,
       expansion_service=None,
       big_lake_configuration=None,
-      type_overrides=None):
+      type_overrides=None,
+      schema_update_options=None):
     """Initialize a WriteToBigQuery transform.
 
     Args:
@@ -2108,6 +2166,14 @@ bigquery_v2_messages.TableSchema`. or a `ValueProvider` 
that has a JSON string,
         These can be 'timePartitioning', 'clustering', etc. They are passed
         directly to the job load configuration. See
         
https://cloud.google.com/bigquery/docs/reference/rest/v2/Job#jobconfigurationload
+      schema_update_options (list): Allows the schema of the destination
+        table to be updated as a side effect of the load job. Each item may be
+        a :class:`BigQuerySchemaUpdateOption` member or its string value.
+        Supported values are
+        :attr:`BigQuerySchemaUpdateOption.ALLOW_FIELD_ADDITION` and
+        :attr:`BigQuerySchemaUpdateOption.ALLOW_FIELD_RELAXATION`. This option
+        is only valid for ``FILE_LOADS`` and cannot be specified together with
+        ``schemaUpdateOptions`` in ``additional_bq_parameters``.
       table_side_inputs (tuple): A tuple with ``AsSideInput`` PCollections to 
be
         passed to the table callable (if one is provided).
       schema_side_inputs: A tuple with ``AsSideInput`` PCollections to be
@@ -2222,6 +2288,8 @@ bigquery_v2_messages.TableSchema`. or a `ValueProvider` 
that has a JSON string,
     self._temp_file_format = temp_file_format or bigquery_tools.FileFormat.JSON
 
     self.additional_bq_parameters = additional_bq_parameters or {}
+    self.schema_update_options = BigQuerySchemaUpdateOption.validate(
+        schema_update_options)
     self.table_side_inputs = table_side_inputs or ()
     self.schema_side_inputs = schema_side_inputs or ()
     self._ignore_insert_ids = ignore_insert_ids
@@ -2252,6 +2320,16 @@ bigquery_v2_messages.TableSchema`. or a `ValueProvider` 
that has a JSON string,
     else:
       return self.method
 
+  def _additional_bq_parameters_for_file_loads(self):
+    if self.schema_update_options is None:
+      return self.additional_bq_parameters
+    if (callable(self.additional_bq_parameters) or
+        isinstance(self.additional_bq_parameters, vp.ValueProvider)):
+      return _AdditionalBQParametersWithSchemaUpdateOptions(
+          self.additional_bq_parameters, self.schema_update_options)
+    return _merge_schema_update_options(
+        self.additional_bq_parameters, self.schema_update_options)
+
   def expand(self, pcoll):
     p = pcoll.pipeline
 
@@ -2270,6 +2348,12 @@ bigquery_v2_messages.TableSchema`. or a `ValueProvider` 
that has a JSON string,
     experiments = p.options.view_as(DebugOptions).experiments or []
     method_to_use = self._compute_method(experiments, is_streaming_pipeline)
 
+    if (self.schema_update_options is not None and
+        method_to_use != WriteToBigQuery.Method.FILE_LOADS):
+      raise ValueError(
+          'schema_update_options is only supported when writing to BigQuery '
+          'with FILE_LOADS.')
+
     if method_to_use == WriteToBigQuery.Method.STREAMING_INSERTS:
       if self.schema == SCHEMA_AUTODETECT:
         raise ValueError(
@@ -2369,7 +2453,8 @@ bigquery_v2_messages.TableSchema`. or a `ValueProvider` 
that has a JSON string,
           test_client=self.test_client,
           table_side_inputs=self.table_side_inputs,
           schema_side_inputs=self.schema_side_inputs,
-          additional_bq_parameters=self.additional_bq_parameters,
+          additional_bq_parameters=(
+              self._additional_bq_parameters_for_file_loads()),
           validate=self._validate,
           is_streaming_pipeline=is_streaming_pipeline,
           load_job_project_id=self.load_job_project_id)
@@ -2448,6 +2533,7 @@ bigquery_v2_messages.TableSchema`. or a `ValueProvider` 
that has a JSON string,
         'method': self.method,
         'insert_retry_strategy': self.insert_retry_strategy,
         'additional_bq_parameters': self.additional_bq_parameters,
+        'schema_update_options': self.schema_update_options,
         'table_side_inputs': table_side_inputs,
         'schema_side_inputs': schema_side_inputs,
         'triggering_frequency': self.triggering_frequency,
diff --git a/sdks/python/apache_beam/io/gcp/bigquery_test.py 
b/sdks/python/apache_beam/io/gcp/bigquery_test.py
index 51d13d96b73..dcadee7f6a1 100644
--- a/sdks/python/apache_beam/io/gcp/bigquery_test.py
+++ b/sdks/python/apache_beam/io/gcp/bigquery_test.py
@@ -1028,7 +1028,10 @@ class TestWriteToBigQuery(unittest.TestCase):
     original = WriteToBigQuery(
         table=lambda _, side_input: side_input['table'],
         table_side_inputs=(table_record_pcv, ),
-        schema=schema)
+        schema=schema,
+        schema_update_options=[
+            beam_bq.BigQuerySchemaUpdateOption.ALLOW_FIELD_ADDITION
+        ])
 
     # pylint: disable=expression-not-assigned
     p | beam.Create([]) | 'MyWriteToBigQuery' >> original
@@ -1070,6 +1073,145 @@ class TestWriteToBigQuery(unittest.TestCase):
         deserialized_side_input_data.window_mapping_fn)
     self.assertEqual(
         original_side_input_data.view_fn, deserialized_side_input_data.view_fn)
+    self.assertEqual(
+        original.schema_update_options, deserialized.schema_update_options)
+
+  def test_schema_update_options_added_to_file_load_parameters(self):
+    additional_bq_parameters = {'timePartitioning': {'type': 'DAY'}}
+    schema_update_options = [
+        beam_bq.BigQuerySchemaUpdateOption.ALLOW_FIELD_ADDITION
+    ]
+    transform = WriteToBigQuery(
+        table='dataset.table',
+        method=WriteToBigQuery.Method.FILE_LOADS,
+        additional_bq_parameters=additional_bq_parameters,
+        schema_update_options=schema_update_options)
+
+    self.assertEqual(['ALLOW_FIELD_ADDITION'], transform.schema_update_options)
+    self.assertIs(type(transform.schema_update_options[0]), str)
+    self.assertEqual({
+        'timePartitioning': {
+            'type': 'DAY'
+        },
+        'schemaUpdateOptions': ['ALLOW_FIELD_ADDITION'],
+    },
+                     transform._additional_bq_parameters_for_file_loads())
+    self.assertNotIn('schemaUpdateOptions', additional_bq_parameters)
+
+  def test_schema_update_options_keeps_additional_bq_parameters_path(self):
+    additional_bq_parameters = {
+        'schemaUpdateOptions': [
+            beam_bq.BigQuerySchemaUpdateOption.ALLOW_FIELD_ADDITION
+        ]
+    }
+    transform = WriteToBigQuery(
+        table='dataset.table',
+        method=WriteToBigQuery.Method.FILE_LOADS,
+        additional_bq_parameters=additional_bq_parameters)
+
+    self.assertEqual(
+        additional_bq_parameters,
+        transform._additional_bq_parameters_for_file_loads())
+
+  def test_schema_update_options_rejects_duplicate_configuration(self):
+    transform = WriteToBigQuery(
+        table='dataset.table',
+        method=WriteToBigQuery.Method.FILE_LOADS,
+        additional_bq_parameters={
+            'schemaUpdateOptions': [
+                beam_bq.BigQuerySchemaUpdateOption.ALLOW_FIELD_RELAXATION
+            ]
+        },
+        schema_update_options=[
+            beam_bq.BigQuerySchemaUpdateOption.ALLOW_FIELD_ADDITION
+        ])
+
+    with self.assertRaisesRegex(ValueError, 'schemaUpdateOptions'):
+      transform._additional_bq_parameters_for_file_loads()
+
+  def test_schema_update_options_rejects_non_list(self):
+    schema_update_option = (
+        beam_bq.BigQuerySchemaUpdateOption.ALLOW_FIELD_ADDITION)
+    with self.assertRaisesRegex(ValueError, 'must be a list'):
+      WriteToBigQuery(
+          table='dataset.table',
+          method=WriteToBigQuery.Method.FILE_LOADS,
+          schema_update_options=schema_update_option)
+
+  def test_schema_update_options_rejects_invalid_value(self):
+    with self.assertRaisesRegex(ValueError, 'Invalid schema update option'):
+      WriteToBigQuery(
+          table='dataset.table',
+          method=WriteToBigQuery.Method.FILE_LOADS,
+          schema_update_options=['INVALID_SCHEMA_UPDATE_OPTION'])
+
+  def test_schema_update_options_accepts_valid_string(self):
+    transform = WriteToBigQuery(
+        table='dataset.table',
+        method=WriteToBigQuery.Method.FILE_LOADS,
+        schema_update_options=['ALLOW_FIELD_RELAXATION'])
+
+    self.assertEqual(['ALLOW_FIELD_RELAXATION'],
+                     transform.schema_update_options)
+
+  def test_schema_update_options_with_callable_additional_bq_parameters(self):
+    schema_update_options = [
+        beam_bq.BigQuerySchemaUpdateOption.ALLOW_FIELD_ADDITION
+    ]
+
+    def additional_bq_parameters(destination):
+      self.assertEqual('project:dataset.table', destination)
+      return {'clustering': {'fields': ['columnA']}}
+
+    transform = WriteToBigQuery(
+        table='dataset.table',
+        method=WriteToBigQuery.Method.FILE_LOADS,
+        additional_bq_parameters=additional_bq_parameters,
+        schema_update_options=schema_update_options)
+
+    additional_parameters = 
transform._additional_bq_parameters_for_file_loads()
+    self.assertEqual({
+        'clustering': {
+            'fields': ['columnA']
+        },
+        'schemaUpdateOptions': schema_update_options,
+    },
+                     additional_parameters('project:dataset.table'))
+
+  def test_schema_update_options_with_value_provider_parameters(self):
+    schema_update_options = [
+        beam_bq.BigQuerySchemaUpdateOption.ALLOW_FIELD_ADDITION
+    ]
+    transform = WriteToBigQuery(
+        table='dataset.table',
+        method=WriteToBigQuery.Method.FILE_LOADS,
+        additional_bq_parameters=StaticValueProvider(
+            dict, {'timePartitioning': {
+                'type': 'DAY'
+            }}),
+        schema_update_options=schema_update_options)
+
+    additional_parameters = 
transform._additional_bq_parameters_for_file_loads()
+    self.assertEqual({
+        'timePartitioning': {
+            'type': 'DAY'
+        },
+        'schemaUpdateOptions': schema_update_options,
+    },
+                     additional_parameters('project:dataset.table'))
+
+  def test_schema_update_options_only_supported_for_file_loads(self):
+    p = TestPipeline()
+    pcoll = p | beam.Create([{'columnA': 'value'}])
+
+    with self.assertRaisesRegex(ValueError, 'FILE_LOADS'):
+      _ = pcoll | WriteToBigQuery(
+          table='dataset.table',
+          schema='columnA:STRING',
+          method=WriteToBigQuery.Method.STREAMING_INSERTS,
+          schema_update_options=[
+              beam_bq.BigQuerySchemaUpdateOption.ALLOW_FIELD_ADDITION
+          ])
 
   def test_streaming_triggering_frequency_without_auto_sharding(self):
     def noop(table, **kwargs):

Reply via email to