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

shahar1 pushed a commit to branch main
in repository https://gitbox.apache.org/repos/asf/airflow.git


The following commit(s) were added to refs/heads/main by this push:
     new 113bc123a6b Warn when ignore_unknown_values is a no-op in 
GCSToBigQueryOperator (#71675)
113bc123a6b is described below

commit 113bc123a6b8c8aa5bad133eea803db8dcd8ced3
Author: Jonathan Gosling <[email protected]>
AuthorDate: Sun Oct 18 08:27:33 2026 +0100

    Warn when ignore_unknown_values is a no-op in GCSToBigQueryOperator (#71675)
    
    Clarify ignore_unknown_values schema behavior in GCSToBigQueryOperator
    
    Warn when CSV or JSON loads use autodetect without allowing field
    addition: values are checked against the inferred schema, not the
    destination table schema. Recommend autodetect=None when the destination
    table exists, or an explicit schema when it may not. Keep the guidance
    accurate for supplied schemas and self-describing formats.
    
    Read the warning conditions from the assembled load configuration and
    test when the warning applies.
    
    ---------
    
    Co-authored-by: Shahar Epstein <[email protected]>
---
 .../google/cloud/transfers/gcs_to_bigquery.py      |  44 ++++++--
 .../google/cloud/transfers/test_gcs_to_bigquery.py | 124 +++++++++++++++++++++
 2 files changed, 161 insertions(+), 7 deletions(-)

diff --git 
a/providers/google/src/airflow/providers/google/cloud/transfers/gcs_to_bigquery.py
 
b/providers/google/src/airflow/providers/google/cloud/transfers/gcs_to_bigquery.py
index 27c284a1848..5f59eff489d 100644
--- 
a/providers/google/src/airflow/providers/google/cloud/transfers/gcs_to_bigquery.py
+++ 
b/providers/google/src/airflow/providers/google/cloud/transfers/gcs_to_bigquery.py
@@ -120,6 +120,9 @@ class GCSToBigQueryOperator(BaseOperator):
         If true, the extra values are ignored. If false, records with extra 
columns
         are treated as bad records, and if there are too many bad records, an
         invalid error is returned in the job result.
+        For CSV and JSON sources with ``autodetect=True`` and no schema 
provided, the values are
+        checked against the schema inferred from the source data rather than 
the destination
+        table's schema - see ``autodetect``.
     :param allow_quoted_newlines: Whether to allow quoted newlines (true) or 
not (false).
     :param allow_jagged_rows: Accept rows that are missing trailing optional 
columns.
         The missing values are treated as nulls. If false, records with 
missing trailing
@@ -162,12 +165,11 @@ class GCSToBigQueryOperator(BaseOperator):
         by one or more columns. BigQuery supports clustering for both 
partitioned and
         non-partitioned tables. The order of columns given determines the sort 
order.
         Not applicable for external tables.
-    :param autodetect: [Optional] Indicates if we should automatically infer 
the
-        options and schema for CSV and JSON sources. (Default: ``True``).
-        Parameter must be set to True if 'schema_fields' and 'schema_object' 
are undefined.
-        It is suggested to set to True if table are create outside of Airflow.
-        If autodetect is None and no schema is provided (neither via 
schema_fields
-        nor a schema_object), assume the table already exists.
+    :param autodetect: [Optional] Whether to infer the schema from the source 
data for CSV and
+        JSON sources when neither ``schema_fields`` nor ``schema_object`` is 
provided. If ``True``,
+        the schema is inferred from the source data. If ``False``, either 
``schema_fields`` or
+        ``schema_object`` must be provided. If ``None``, the existing 
destination table's schema
+        is used. (Default: ``True``).
     :param encryption_configuration: [Optional] Custom encryption 
configuration (e.g., Cloud KMS keys).
 
         .. code-block:: python
@@ -240,7 +242,7 @@ class GCSToBigQueryOperator(BaseOperator):
         time_partitioning=None,
         range_partitioning=None,
         cluster_fields=None,
-        autodetect=True,
+        autodetect: bool | None = True,
         encryption_configuration=None,
         location=None,
         impersonation_chain: str | Sequence[str] | None = None,
@@ -774,6 +776,34 @@ class GCSToBigQueryOperator(BaseOperator):
         if self.extra_config:
             self.configuration["load"].update(self.extra_config)
 
+        # Checked against the assembled load config rather than the operator 
attributes, because
+        # src_fmt_configs and extra_config can still override any of these 
keys.
+        # Self-describing formats (Avro, Parquet, ORC, Datastore backups) 
carry their own schema and
+        # ignoreUnknownValues still drops file fields missing from the table, 
so only CSV and JSON apply.
+        load_config = self.configuration["load"]
+        if (
+            load_config.get("sourceFormat") in ("CSV", 
"NEWLINE_DELIMITED_JSON")
+            and load_config.get("autodetect")
+            and load_config.get("ignoreUnknownValues")
+            and "schema" not in load_config
+            and "ALLOW_FIELD_ADDITION" not in 
(load_config.get("schemaUpdateOptions") or [])
+        ):
+            # autodetect=None only works against a table that exists when the 
load job runs,
+            # and force_delete deletes it first.
+            if self.force_delete:
+                fix = "Pass `schema_fields` or `schema_object` instead."
+            else:
+                fix = (
+                    "If the destination table already exists, set 
`autodetect=None` to use its "
+                    "schema; otherwise pass `schema_fields` or 
`schema_object`."
+                )
+            self.log.warning(
+                "`ignore_unknown_values` is checked against the schema 
autodetected from the source "
+                "data, not against the destination table's schema, so fields 
missing from the "
+                "destination table are not dropped. %s",
+                fix,
+            )
+
         return self.configuration
 
     def _validate_src_fmt_configs(
diff --git 
a/providers/google/tests/unit/google/cloud/transfers/test_gcs_to_bigquery.py 
b/providers/google/tests/unit/google/cloud/transfers/test_gcs_to_bigquery.py
index 18007fff91a..496207fbf40 100644
--- a/providers/google/tests/unit/google/cloud/transfers/test_gcs_to_bigquery.py
+++ b/providers/google/tests/unit/google/cloud/transfers/test_gcs_to_bigquery.py
@@ -2151,6 +2151,130 @@ class TestGCSToBigQueryOperator:
 
         assert operator.schema_fields == SCHEMA_FIELDS
 
+    @pytest.mark.parametrize(
+        ("operator_kwargs", "expects_warning"),
+        [
+            pytest.param({"ignore_unknown_values": True}, True, 
id="autodetect_defaults_to_true"),
+            pytest.param({"ignore_unknown_values": True, "autodetect": True}, 
True, id="autodetect_true"),
+            pytest.param({"ignore_unknown_values": True, "autodetect": None}, 
False, id="autodetect_none"),
+            pytest.param(
+                {"ignore_unknown_values": True, "autodetect": False, 
"schema_fields": SCHEMA_FIELDS},
+                False,
+                id="autodetect_false",
+            ),
+            pytest.param(
+                {"ignore_unknown_values": True, "schema_fields": 
SCHEMA_FIELDS},
+                False,
+                id="schema_fields_supplied",
+            ),
+            pytest.param({"ignore_unknown_values": False}, False, 
id="ignore_unknown_values_off"),
+            pytest.param(
+                {"ignore_unknown_values": True, "extra_config": {"autodetect": 
None}},
+                False,
+                id="autodetect_cleared_by_extra_config",
+            ),
+            pytest.param(
+                {"ignore_unknown_values": True, "extra_config": {"schema": 
{"fields": SCHEMA_FIELDS}}},
+                False,
+                id="schema_supplied_by_extra_config",
+            ),
+            pytest.param(
+                {"ignore_unknown_values": False, "extra_config": 
{"ignoreUnknownValues": True}},
+                True,
+                id="ignore_unknown_values_set_by_extra_config",
+            ),
+            pytest.param(
+                {"ignore_unknown_values": True, "source_format": 
"NEWLINE_DELIMITED_JSON"},
+                True,
+                id="json_source",
+            ),
+            pytest.param(
+                {"ignore_unknown_values": True, "source_format": "PARQUET"}, 
False, id="parquet_source"
+            ),
+            pytest.param({"ignore_unknown_values": True, "source_format": 
"AVRO"}, False, id="avro_source"),
+            pytest.param({"ignore_unknown_values": True, "source_format": 
"ORC"}, False, id="orc_source"),
+            pytest.param(
+                {"ignore_unknown_values": True, "extra_config": 
{"sourceFormat": "PARQUET"}},
+                False,
+                id="source_format_set_by_extra_config",
+            ),
+            pytest.param(
+                {"ignore_unknown_values": True, "schema_update_options": 
["ALLOW_FIELD_ADDITION"]},
+                False,
+                id="field_addition_allowed",
+            ),
+            pytest.param(
+                {"ignore_unknown_values": True, "schema_update_options": 
["ALLOW_FIELD_RELAXATION"]},
+                True,
+                id="only_field_relaxation_allowed",
+            ),
+        ],
+    )
+    @mock.patch(GCS_TO_BQ_PATH.format("BigQueryHook"))
+    def test_ignore_unknown_values_autodetect_warning(self, bq_hook, 
operator_kwargs, expects_warning):
+        bq_hook.return_value.insert_job.side_effect = [
+            MagicMock(job_id=REAL_JOB_ID, error_result=False),
+            REAL_JOB_ID,
+        ]
+        bq_hook.return_value.generate_job_id.return_value = REAL_JOB_ID
+        bq_hook.return_value.split_tablename.return_value = (PROJECT_ID, 
DATASET, TABLE)
+
+        operator = GCSToBigQueryOperator(
+            task_id=TASK_ID,
+            bucket=TEST_BUCKET,
+            source_objects=TEST_SOURCE_OBJECTS,
+            destination_project_dataset_table=TEST_EXPLICIT_DEST,
+            write_disposition=WRITE_DISPOSITION,
+            project_id=JOB_PROJECT_ID,
+            **operator_kwargs,
+        )
+
+        with mock.patch.object(operator.log, "warning", autospec=True) as 
mock_warning:
+            operator.execute(context=MagicMock())
+
+        if expects_warning:
+            mock_warning.assert_called_once()
+        else:
+            mock_warning.assert_not_called()
+
+    @pytest.mark.parametrize(
+        ("force_delete", "expected_fix", "unexpected_fix"),
+        [
+            pytest.param(False, "autodetect=None", None, id="existing_table"),
+            pytest.param(True, "schema_fields", "autodetect=None", 
id="force_delete"),
+        ],
+    )
+    @mock.patch(GCS_TO_BQ_PATH.format("BigQueryHook"))
+    def test_ignore_unknown_values_autodetect_warning_names_the_fix(
+        self, bq_hook, force_delete, expected_fix, unexpected_fix
+    ):
+        bq_hook.return_value.insert_job.side_effect = [
+            MagicMock(job_id=REAL_JOB_ID, error_result=False),
+            REAL_JOB_ID,
+        ]
+        bq_hook.return_value.generate_job_id.return_value = REAL_JOB_ID
+        bq_hook.return_value.split_tablename.return_value = (PROJECT_ID, 
DATASET, TABLE)
+
+        operator = GCSToBigQueryOperator(
+            task_id=TASK_ID,
+            bucket=TEST_BUCKET,
+            source_objects=TEST_SOURCE_OBJECTS,
+            destination_project_dataset_table=TEST_EXPLICIT_DEST,
+            write_disposition=WRITE_DISPOSITION,
+            project_id=JOB_PROJECT_ID,
+            ignore_unknown_values=True,
+            force_delete=force_delete,
+        )
+
+        with mock.patch.object(operator.log, "warning", autospec=True) as 
mock_warning:
+            operator.execute(context=MagicMock())
+
+        mock_warning.assert_called_once()
+        message = mock_warning.call_args.args[0] % 
mock_warning.call_args.args[1:]
+        assert expected_fix in message
+        if unexpected_fix:
+            assert unexpected_fix not in message
+
 
 @pytest.fixture
 def create_task_instance(create_task_instance_of_operator, session):

Reply via email to