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

eladkal 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 4bfafaeca2f Add query tags to `DatabricksSqlSensor` and 
`DatabricksPartitionSensor` (#68704)
4bfafaeca2f is described below

commit 4bfafaeca2f68e6c5fdaa3c8cb6f885884947e1e
Author: Akshaykumar <[email protected]>
AuthorDate: Thu Jun 25 22:13:31 2026 +0530

    Add query tags to `DatabricksSqlSensor` and `DatabricksPartitionSensor` 
(#68704)
    
    * Add query tags: DatabricksSqlSensor, DatabricksPartitionSensor, 
DatabricksSQLStatementsOperator, DatabricksSQLStatementsSensor
    
    * Fix CI
    
    * Address review
    
    Co-Authored-By: Otto <[email protected]>
    
    ---------
    
    Co-authored-by: Akshay <[email protected]>
    Co-authored-by: Otto <[email protected]>
---
 providers/databricks/docs/operators/sql.rst        |  18 ++++
 .../databricks/docs/operators/sql_statements.rst   |   8 ++
 .../providers/databricks/operators/databricks.py   |  17 +++-
 .../databricks/operators/databricks_sql.py         |  37 +------
 .../providers/databricks/sensors/databricks.py     |   9 ++
 .../databricks/sensors/databricks_partition.py     |  14 +++
 .../providers/databricks/sensors/databricks_sql.py |  12 +++
 .../providers/databricks/utils/query_tags.py       |  75 +++++++++++++++
 .../unit/databricks/operators/test_databricks.py   | 101 ++++++++++++++++++++
 .../databricks/operators/test_databricks_sql.py    |   6 --
 .../unit/databricks/sensors/test_databricks.py     |  90 +++++++++++++++++
 .../sensors/test_databricks_partition.py           |  69 ++++++++++++++
 .../unit/databricks/sensors/test_databricks_sql.py |  69 ++++++++++++++
 .../tests/unit/databricks/utils/test_query_tags.py | 106 +++++++++++++++++++++
 14 files changed, 589 insertions(+), 42 deletions(-)

diff --git a/providers/databricks/docs/operators/sql.rst 
b/providers/databricks/docs/operators/sql.rst
index 448fce7bbf8..e0840e60987 100644
--- a/providers/databricks/docs/operators/sql.rst
+++ b/providers/databricks/docs/operators/sql.rst
@@ -103,6 +103,24 @@ The sensor executes the SQL statement supplied by the 
user. The only required pa
 
 Other parameters are optional and could be found in the class documentation.
 
+Attaching query tags
+^^^^^^^^^^^^^^^^^^^^
+
+Both ``DatabricksSqlSensor`` and ``DatabricksPartitionSensor`` support 
``query_tags`` and
+``include_airflow_query_tags`` to attach metadata to every query sent to 
Databricks.
+
+.. code-block:: python
+
+    sensor = DatabricksSqlSensor(
+        task_id="sensor_with_tags",
+        sql_warehouse_name="my_warehouse",
+        sql="SELECT 1 FROM my_table WHERE status = 'ready'",
+        query_tags={"team": "data-eng", "env": "prod"},  # merged with Airflow 
context tags
+        include_airflow_query_tags=True,  # adds dag_id, task_id, run_id, 
try_number, map_index
+    )
+
+Set ``include_airflow_query_tags=False`` to suppress the automatic Airflow 
context tags.
+
 Examples
 --------
 Configuring Databricks connection to be used with the Sensor.
diff --git a/providers/databricks/docs/operators/sql_statements.rst 
b/providers/databricks/docs/operators/sql_statements.rst
index 9d314a238da..58954a06fea 100644
--- a/providers/databricks/docs/operators/sql_statements.rst
+++ b/providers/databricks/docs/operators/sql_statements.rst
@@ -45,6 +45,10 @@ but not limited to:
 * ``catalog``
 * ``schema``
 * ``parameters``
+* ``query_tags``
+* ``include_airflow_query_tags`` - When ``True`` (default), Airflow context 
metadata
+  (``dag_id``, ``task_id``, ``run_id``, ``try_number``, ``map_index``) is 
merged into
+  ``query_tags`` automatically. Set to ``False`` to suppress these automatic 
tags.
 
 Examples
 --------
@@ -91,6 +95,10 @@ but not limited to:
 * ``catalog``
 * ``schema``
 * ``parameters``
+* ``query_tags``
+* ``include_airflow_query_tags`` - When ``True`` (default), Airflow context 
metadata
+  (``dag_id``, ``task_id``, ``run_id``, ``try_number``, ``map_index``) is 
merged into
+  ``query_tags`` automatically. Set to ``False`` to suppress these automatic 
tags.
 
 Examples
 --------
diff --git 
a/providers/databricks/src/airflow/providers/databricks/operators/databricks.py 
b/providers/databricks/src/airflow/providers/databricks/operators/databricks.py
index 4bae6b28f0a..0eba46a6bca 100644
--- 
a/providers/databricks/src/airflow/providers/databricks/operators/databricks.py
+++ 
b/providers/databricks/src/airflow/providers/databricks/operators/databricks.py
@@ -54,6 +54,7 @@ from airflow.providers.databricks.utils.databricks import (
     validate_trigger_event,
 )
 from airflow.providers.databricks.utils.mixins import 
DatabricksSQLStatementsMixin
+from airflow.providers.databricks.utils.query_tags import build_query_tags, 
dict_to_query_tag_list
 from airflow.providers.databricks.version_compat import AIRFLOW_V_3_0_PLUS
 
 if TYPE_CHECKING:
@@ -1297,10 +1298,15 @@ class 
DatabricksSQLStatementsOperator(DatabricksSQLStatementsMixin, BaseOperator
     :param do_xcom_push: Whether we should push statement_id to xcom.:
     :param timeout: The timeout for the Airflow task executing the SQL 
statement. By default a value of 3600 seconds is used.
     :param deferrable: Run operator in the deferrable mode.
+    :param query_tags: Optional dictionary of query tags to attach to the SQL 
statement. Tags are
+        passed as the ``query_tags`` field in the Databricks Statement 
Execution REST API request body.
+        See 
https://docs.databricks.com/api/workspace/statementexecution/executestatement
+    :param include_airflow_query_tags: If True, add Airflow DAG/task/run 
metadata as query tags.
+        Defaults to True.
     """
 
     # Used in airflow.models.BaseOperator
-    template_fields: Sequence[str] = ("databricks_conn_id",)
+    template_fields: Sequence[str] = ("databricks_conn_id", "query_tags")
     template_ext: Sequence[str] = (".json-tpl",)
     # Databricks brand color (blue) under white text
     ui_color = "#1CB1C2"
@@ -1323,9 +1329,11 @@ class 
DatabricksSQLStatementsOperator(DatabricksSQLStatementsMixin, BaseOperator
         wait_for_termination: bool = True,
         timeout: float = 3600,
         deferrable: bool = conf.getboolean("operators", "default_deferrable", 
fallback=False),
+        query_tags: dict[str, str | None] | None = None,
+        include_airflow_query_tags: bool = True,
         **kwargs,
     ) -> None:
-        """Create a new ``DatabricksSubmitRunOperator``."""
+        """Create a new ``DatabricksSQLStatementsOperator``."""
         super().__init__(**kwargs)
         self.statement = statement
         self.warehouse_id = warehouse_id
@@ -1339,6 +1347,8 @@ class 
DatabricksSQLStatementsOperator(DatabricksSQLStatementsMixin, BaseOperator
         self.databricks_retry_args = databricks_retry_args
         self.wait_for_termination = wait_for_termination
         self.deferrable = deferrable
+        self.query_tags = query_tags or {}
+        self.include_airflow_query_tags = include_airflow_query_tags
 
         # This variable will be used in case our task gets killed.
         self.statement_id: str | None = None
@@ -1360,6 +1370,7 @@ class 
DatabricksSQLStatementsOperator(DatabricksSQLStatementsMixin, BaseOperator
         )
 
     def execute(self, context: Context):
+        tags = build_query_tags(context, self.query_tags, 
self.include_airflow_query_tags)
         json = {
             "statement": self.statement,
             "warehouse_id": self.warehouse_id,
@@ -1371,6 +1382,8 @@ class 
DatabricksSQLStatementsOperator(DatabricksSQLStatementsMixin, BaseOperator
             # execution state.
             "wait_timeout": "0s",
         }
+        if tags:
+            json["query_tags"] = dict_to_query_tag_list(tags)
         self.statement_id = self._hook.post_sql_statement(json)
         if self.do_xcom_push and context is not None:
             context["ti"].xcom_push(key=XCOM_STATEMENT_ID_KEY, 
value=self.statement_id)
diff --git 
a/providers/databricks/src/airflow/providers/databricks/operators/databricks_sql.py
 
b/providers/databricks/src/airflow/providers/databricks/operators/databricks_sql.py
index f72514f2488..f025bc09622 100644
--- 
a/providers/databricks/src/airflow/providers/databricks/operators/databricks_sql.py
+++ 
b/providers/databricks/src/airflow/providers/databricks/operators/databricks_sql.py
@@ -38,6 +38,7 @@ from airflow.providers.common.compat.sdk import (
 )
 from airflow.providers.common.sql.operators.sql import SQLExecuteQueryOperator
 from airflow.providers.databricks.hooks.databricks_sql import DatabricksSqlHook
+from airflow.providers.databricks.utils.query_tags import build_query_tags
 
 if TYPE_CHECKING:
     from airflow.providers.common.compat.sdk import Context
@@ -46,24 +47,6 @@ _IDENTIFIER_RE = re.compile(r"^[A-Za-z_][A-Za-z0-9_]*$")
 _DISALLOWED_SQL_TOKENS = (";", "--", "/*", "*/")
 
 
-def _get_airflow_query_tags(context: Context) -> dict[str, str | None]:
-    """Return Airflow context metadata as a query-tags dict."""
-    task_instance = context.get("ti")
-    if task_instance is None:
-        return {}
-
-    def _as_str(value: Any) -> str | None:
-        return None if value is None else str(value)
-
-    return {
-        "airflow_dag_id": _as_str(task_instance.dag_id),
-        "airflow_task_id": _as_str(task_instance.task_id),
-        "airflow_run_id": _as_str(task_instance.run_id),
-        "airflow_try_number": _as_str(task_instance.try_number),
-        "airflow_map_index": _as_str(task_instance.map_index),
-    }
-
-
 class DatabricksSqlOperator(SQLExecuteQueryOperator):
     """
     Executes SQL code in a Databricks SQL endpoint or a Databricks cluster.
@@ -329,14 +312,7 @@ class DatabricksSqlOperator(SQLExecuteQueryOperator):
         return list(zip(descriptions, results))
 
     def _get_query_tags(self, context: Context) -> dict[str, str | None] | 
None:
-        query_tags: dict[str, str | None] = {}
-
-        if self.include_airflow_query_tags and context is not None:
-            query_tags.update(_get_airflow_query_tags(context))
-
-        query_tags.update(self.query_tags)
-
-        return query_tags or None
+        return build_query_tags(context, self.query_tags, 
self.include_airflow_query_tags)
 
     def execute(self, context: Context) -> Any:
         self.get_db_hook().query_tags = self._get_query_tags(context)
@@ -561,14 +537,7 @@ FILEFORMAT = {self._file_format}
         return sql.strip()
 
     def _get_query_tags(self, context: Context) -> dict[str, str | None] | 
None:
-        query_tags: dict[str, str | None] = {}
-
-        if self.include_airflow_query_tags and context is not None:
-            query_tags.update(_get_airflow_query_tags(context))
-
-        query_tags.update(self.query_tags)
-
-        return query_tags or None
+        return build_query_tags(context, self.query_tags, 
self.include_airflow_query_tags)
 
     def execute(self, context: Context) -> Any:
         self._sql = self._create_sql_query()
diff --git 
a/providers/databricks/src/airflow/providers/databricks/sensors/databricks.py 
b/providers/databricks/src/airflow/providers/databricks/sensors/databricks.py
index 74fc0c2ecef..9497cf8624d 100644
--- 
a/providers/databricks/src/airflow/providers/databricks/sensors/databricks.py
+++ 
b/providers/databricks/src/airflow/providers/databricks/sensors/databricks.py
@@ -26,6 +26,7 @@ from airflow.providers.common.compat.sdk import 
AirflowException, BaseSensorOper
 from airflow.providers.databricks.hooks.databricks import DatabricksHook, 
SQLStatementState
 from airflow.providers.databricks.operators.databricks import DEFER_METHOD_NAME
 from airflow.providers.databricks.utils.mixins import 
DatabricksSQLStatementsMixin
+from airflow.providers.databricks.utils.query_tags import build_query_tags, 
dict_to_query_tag_list
 
 if TYPE_CHECKING:
     from airflow.providers.common.compat.sdk import Context
@@ -40,6 +41,7 @@ class 
DatabricksSQLStatementsSensor(DatabricksSQLStatementsMixin, BaseSensorOper
         "databricks_conn_id",
         "statement",
         "statement_id",
+        "query_tags",
     )
     template_ext: Sequence[str] = (".json-tpl",)
     ui_color = "#1CB1C2"
@@ -63,6 +65,8 @@ class 
DatabricksSQLStatementsSensor(DatabricksSQLStatementsMixin, BaseSensorOper
         wait_for_termination: bool = True,
         timeout: float = 3600,
         deferrable: bool = conf.getboolean("operators", "default_deferrable", 
fallback=False),
+        query_tags: dict[str, str | None] | None = None,
+        include_airflow_query_tags: bool = True,
         **kwargs,
     ):
         # Handle the scenario where either both statement and statement_id are 
set/not set
@@ -91,6 +95,8 @@ class 
DatabricksSQLStatementsSensor(DatabricksSQLStatementsMixin, BaseSensorOper
         self.deferrable = deferrable
         self.timeout = timeout
         self.do_xcom_push = do_xcom_push
+        self.query_tags = query_tags or {}
+        self.include_airflow_query_tags = include_airflow_query_tags
 
     @cached_property
     def _hook(self):
@@ -108,6 +114,7 @@ class 
DatabricksSQLStatementsSensor(DatabricksSQLStatementsMixin, BaseSensorOper
     def execute(self, context: Context):
         if not self.statement_id:
             # Otherwise, we'll go ahead and "submit" the statement
+            tags = build_query_tags(context, self.query_tags, 
self.include_airflow_query_tags)
             json = {
                 "statement": self.statement,
                 "warehouse_id": self.warehouse_id,
@@ -116,6 +123,8 @@ class 
DatabricksSQLStatementsSensor(DatabricksSQLStatementsMixin, BaseSensorOper
                 "parameters": self.parameters,
                 "wait_timeout": "0s",
             }
+            if tags:
+                json["query_tags"] = dict_to_query_tag_list(tags)
 
             self.statement_id = self._hook.post_sql_statement(json)
             self.log.info("SQL Statement submitted with statement_id: %s", 
self.statement_id)
diff --git 
a/providers/databricks/src/airflow/providers/databricks/sensors/databricks_partition.py
 
b/providers/databricks/src/airflow/providers/databricks/sensors/databricks_partition.py
index 2036dca97c3..ad8429cea47 100644
--- 
a/providers/databricks/src/airflow/providers/databricks/sensors/databricks_partition.py
+++ 
b/providers/databricks/src/airflow/providers/databricks/sensors/databricks_partition.py
@@ -30,6 +30,7 @@ from databricks.sql.utils import ParamEscaper
 from airflow.providers.common.compat.sdk import AirflowException, 
BaseSensorOperator
 from airflow.providers.common.sql.hooks.handlers import fetch_all_handler
 from airflow.providers.databricks.hooks.databricks_sql import DatabricksSqlHook
+from airflow.providers.databricks.utils.query_tags import build_query_tags
 
 if TYPE_CHECKING:
     from airflow.providers.common.compat.sdk import Context
@@ -61,6 +62,11 @@ class DatabricksPartitionSensor(BaseSensorOperator):
     :param partition_operator: Optional comparison operator for partitions, 
such as >=.
     :param handler: Handler for DbApiHook.run() to return results, defaults to 
fetch_all_handler
     :param client_parameters: Additional parameters internal to Databricks SQL 
connector parameters.
+    :param query_tags: Optional dictionary of query tags to attach to 
Databricks SQL queries.
+        Tags are injected via the ``QUERY_TAGS`` Databricks session parameter 
so they appear in
+        ``system.query.history``. (templated)
+    :param include_airflow_query_tags: If True, add Airflow DAG/task/run 
metadata as query tags.
+        Defaults to True.
     """
 
     template_fields: Sequence[str] = (
@@ -70,6 +76,7 @@ class DatabricksPartitionSensor(BaseSensorOperator):
         "table_name",
         "partitions",
         "http_headers",
+        "query_tags",
     )
 
     template_ext: Sequence[str] = (".sql",)
@@ -90,6 +97,8 @@ class DatabricksPartitionSensor(BaseSensorOperator):
         partition_operator: str = "=",
         handler: Callable[[Any], Any] = fetch_all_handler,
         client_parameters: dict[str, Any] | None = None,
+        query_tags: dict[str, str | None] | None = None,
+        include_airflow_query_tags: bool = True,
         **kwargs,
     ) -> None:
         self.databricks_conn_id = databricks_conn_id
@@ -106,6 +115,8 @@ class DatabricksPartitionSensor(BaseSensorOperator):
         self.client_parameters = client_parameters or {}
         self.hook_params = kwargs.pop("hook_params", {})
         self.handler = handler
+        self.query_tags = query_tags or {}
+        self.include_airflow_query_tags = include_airflow_query_tags
         self.escaper = ParamEscaper()
         super().__init__(**kwargs)
 
@@ -218,6 +229,9 @@ class DatabricksPartitionSensor(BaseSensorOperator):
 
     def poke(self, context: Context) -> bool:
         """Check the table partitions and return the results."""
+        self._get_hook.query_tags = build_query_tags(
+            context, self.query_tags, self.include_airflow_query_tags
+        )
         partition_result = self._check_table_partitions()
         self.log.debug("Partition sensor result: %s", partition_result)
         if partition_result:
diff --git 
a/providers/databricks/src/airflow/providers/databricks/sensors/databricks_sql.py
 
b/providers/databricks/src/airflow/providers/databricks/sensors/databricks_sql.py
index 6ab52df67b5..90c0b8de5dd 100644
--- 
a/providers/databricks/src/airflow/providers/databricks/sensors/databricks_sql.py
+++ 
b/providers/databricks/src/airflow/providers/databricks/sensors/databricks_sql.py
@@ -27,6 +27,7 @@ from typing import TYPE_CHECKING, Any
 from airflow.providers.common.compat.sdk import AirflowException, 
BaseSensorOperator
 from airflow.providers.common.sql.hooks.handlers import fetch_all_handler
 from airflow.providers.databricks.hooks.databricks_sql import DatabricksSqlHook
+from airflow.providers.databricks.utils.query_tags import build_query_tags
 
 if TYPE_CHECKING:
     from airflow.providers.common.compat.sdk import Context
@@ -55,6 +56,11 @@ class DatabricksSqlSensor(BaseSensorOperator):
     :param sql: SQL statement to be executed.
     :param handler: Handler for DbApiHook.run() to return results, defaults to 
fetch_all_handler
     :param client_parameters: Additional parameters internal to Databricks SQL 
connector parameters.
+    :param query_tags: Optional dictionary of query tags to attach to 
Databricks SQL queries.
+        Tags are injected via the ``QUERY_TAGS`` Databricks session parameter 
so they appear in
+        ``system.query.history``. (templated)
+    :param include_airflow_query_tags: If True, add Airflow DAG/task/run 
metadata as query tags.
+        Defaults to True.
     """
 
     template_fields: Sequence[str] = (
@@ -63,6 +69,7 @@ class DatabricksSqlSensor(BaseSensorOperator):
         "catalog",
         "schema",
         "http_headers",
+        "query_tags",
     )
 
     template_ext: Sequence[str] = (".sql",)
@@ -81,6 +88,8 @@ class DatabricksSqlSensor(BaseSensorOperator):
         sql: str | Iterable[str],
         handler: Callable[[Any], Any] = fetch_all_handler,
         client_parameters: dict[str, Any] | None = None,
+        query_tags: dict[str, str | None] | None = None,
+        include_airflow_query_tags: bool = True,
         **kwargs,
     ) -> None:
         """Create DatabricksSqlSensor object using the specified input 
arguments."""
@@ -96,6 +105,8 @@ class DatabricksSqlSensor(BaseSensorOperator):
         self.client_parameters = client_parameters or {}
         self.hook_params = kwargs.pop("hook_params", {})
         self.handler = handler
+        self.query_tags = query_tags or {}
+        self.include_airflow_query_tags = include_airflow_query_tags
         super().__init__(**kwargs)
 
     @cached_property
@@ -132,4 +143,5 @@ class DatabricksSqlSensor(BaseSensorOperator):
 
     def poke(self, context: Context) -> bool:
         """Sensor poke function to get and return results from the SQL 
sensor."""
+        self.hook.query_tags = build_query_tags(context, self.query_tags, 
self.include_airflow_query_tags)
         return self._get_results()
diff --git 
a/providers/databricks/src/airflow/providers/databricks/utils/query_tags.py 
b/providers/databricks/src/airflow/providers/databricks/utils/query_tags.py
new file mode 100644
index 00000000000..90b58d7539a
--- /dev/null
+++ b/providers/databricks/src/airflow/providers/databricks/utils/query_tags.py
@@ -0,0 +1,75 @@
+#
+# Licensed to the Apache Software Foundation (ASF) under one
+# or more contributor license agreements.  See the NOTICE file
+# distributed with this work for additional information
+# regarding copyright ownership.  The ASF licenses this file
+# to you under the Apache License, Version 2.0 (the
+# "License"); you may not use this file except in compliance
+# with the License.  You may obtain a copy of the License at
+#
+#   http://www.apache.org/licenses/LICENSE-2.0
+#
+# Unless required by applicable law or agreed to in writing,
+# software distributed under the License is distributed on an
+# "AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY
+# KIND, either express or implied.  See the License for the
+# specific language governing permissions and limitations
+# under the License.
+#
+"""Shared utilities for Databricks query-tag handling."""
+
+from __future__ import annotations
+
+from typing import TYPE_CHECKING, Any
+
+if TYPE_CHECKING:
+    from airflow.providers.common.compat.sdk import Context
+
+
+def get_airflow_query_tags(context: Context) -> dict[str, str | None]:
+    """Return Airflow context metadata as a query-tags dict."""
+    task_instance = context.get("ti")
+    if task_instance is None:
+        return {}
+
+    def _as_str(value: Any) -> str | None:
+        return None if value is None else str(value)
+
+    return {
+        "airflow_dag_id": _as_str(task_instance.dag_id),
+        "airflow_task_id": _as_str(task_instance.task_id),
+        "airflow_run_id": _as_str(task_instance.run_id),
+        "airflow_try_number": _as_str(task_instance.try_number),
+        "airflow_map_index": _as_str(task_instance.map_index),
+    }
+
+
+def build_query_tags(
+    context: Context | None,
+    user_query_tags: dict[str, str | None],
+    include_airflow_query_tags: bool,
+) -> dict[str, str | None] | None:
+    """
+    Merge Airflow context tags with user-supplied tags.
+
+    Airflow tags are added first; user-supplied tags override on key collision.
+    Returns ``None`` when the resulting dict is empty so callers can skip
+    injection entirely.
+    """
+    tags: dict[str, str | None] = {}
+    if include_airflow_query_tags and context is not None:
+        tags.update(get_airflow_query_tags(context))
+    tags.update(user_query_tags)
+    return tags or None
+
+
+def dict_to_query_tag_list(tags: dict[str, str | None]) -> list[dict[str, 
str]]:
+    """
+    Convert a ``{key: value}`` dict to the ``[{"key": ..., "value": ...}]`` 
list format.
+
+    Required by the Databricks Statement Execution REST API.
+
+    See: 
https://docs.databricks.com/api/workspace/statementexecution/executestatement
+    Entries whose value is ``None`` are omitted.
+    """
+    return [{"key": k, "value": v} for k, v in tags.items() if v is not None]
diff --git 
a/providers/databricks/tests/unit/databricks/operators/test_databricks.py 
b/providers/databricks/tests/unit/databricks/operators/test_databricks.py
index f44f1e9fbe0..455fbac8420 100644
--- a/providers/databricks/tests/unit/databricks/operators/test_databricks.py
+++ b/providers/databricks/tests/unit/databricks/operators/test_databricks.py
@@ -2981,6 +2981,107 @@ class TestDatabricksSQLStatementsOperator:
         }
         assert result.job_facets == {"sql": SQLJobFacet(query="normalized" + 
query)}
 
+    def test_query_tags_defaults(self):
+        op = DatabricksSQLStatementsOperator(
+            task_id=TASK_ID, statement="select * from test.test;", 
warehouse_id=WAREHOUSE_ID
+        )
+        assert op.query_tags == {}
+        assert op.include_airflow_query_tags is True
+
+    def test_query_tags_in_template_fields(self):
+        assert "query_tags" in DatabricksSQLStatementsOperator.template_fields
+
+    def test_query_tags_stored(self):
+        op = DatabricksSQLStatementsOperator(
+            task_id=TASK_ID,
+            statement="select * from test.test;",
+            warehouse_id=WAREHOUSE_ID,
+            query_tags={"env": "prod"},
+        )
+        assert op.query_tags == {"env": "prod"}
+
+    
@mock.patch("airflow.providers.databricks.operators.databricks.DatabricksHook")
+    def test_execute_passes_query_tags_to_post_sql_statement(self, 
db_mock_class):
+        db_mock = db_mock_class.return_value
+        db_mock.post_sql_statement.return_value = STATEMENT_ID
+
+        op = DatabricksSQLStatementsOperator(
+            task_id=TASK_ID,
+            statement="select * from test.test;",
+            warehouse_id=WAREHOUSE_ID,
+            query_tags={"team": "data"},
+            include_airflow_query_tags=False,
+        )
+        op.execute(None)
+
+        posted_json = db_mock.post_sql_statement.call_args[0][0]
+        assert posted_json["query_tags"] == [{"key": "team", "value": "data"}]
+
+    
@mock.patch("airflow.providers.databricks.operators.databricks.DatabricksHook")
+    def test_execute_omits_query_tags_when_none(self, db_mock_class):
+        db_mock = db_mock_class.return_value
+        db_mock.post_sql_statement.return_value = STATEMENT_ID
+
+        op = DatabricksSQLStatementsOperator(
+            task_id=TASK_ID,
+            statement="select * from test.test;",
+            warehouse_id=WAREHOUSE_ID,
+            include_airflow_query_tags=False,
+        )
+        op.execute(None)
+
+        posted_json = db_mock.post_sql_statement.call_args[0][0]
+        assert "query_tags" not in posted_json
+
+    
@mock.patch("airflow.providers.databricks.operators.databricks.DatabricksHook")
+    def test_execute_includes_airflow_query_tags(self, db_mock_class):
+        db_mock = db_mock_class.return_value
+        db_mock.post_sql_statement.return_value = STATEMENT_ID
+
+        op = DatabricksSQLStatementsOperator(
+            task_id=TASK_ID,
+            statement="select * from test.test;",
+            warehouse_id=WAREHOUSE_ID,
+            query_tags={"custom": "value"},
+        )
+        mock_ti = mock.MagicMock(spec=["dag_id", "task_id", "run_id", 
"try_number", "map_index", "xcom_push"])
+        mock_ti.dag_id = "my_dag"
+        mock_ti.task_id = "my_task"
+        mock_ti.run_id = "run_1"
+        mock_ti.try_number = 1
+        mock_ti.map_index = -1
+
+        op.execute(context={"ti": mock_ti})
+
+        posted_json = db_mock.post_sql_statement.call_args[0][0]
+        tag_keys = {t["key"] for t in posted_json["query_tags"]}
+        assert "airflow_dag_id" in tag_keys
+        assert "custom" in tag_keys
+
+    
@mock.patch("airflow.providers.databricks.operators.databricks.DatabricksHook")
+    def test_custom_query_tags_override_airflow_tags(self, db_mock_class):
+        db_mock = db_mock_class.return_value
+        db_mock.post_sql_statement.return_value = STATEMENT_ID
+
+        op = DatabricksSQLStatementsOperator(
+            task_id=TASK_ID,
+            statement="select * from test.test;",
+            warehouse_id=WAREHOUSE_ID,
+            query_tags={"airflow_dag_id": "overridden"},
+        )
+        mock_ti = mock.MagicMock(spec=["dag_id", "task_id", "run_id", 
"try_number", "map_index", "xcom_push"])
+        mock_ti.dag_id = "original"
+        mock_ti.task_id = "t"
+        mock_ti.run_id = "r"
+        mock_ti.try_number = 1
+        mock_ti.map_index = -1
+
+        op.execute(context={"ti": mock_ti})
+
+        posted_json = db_mock.post_sql_statement.call_args[0][0]
+        tag_map = {t["key"]: t["value"] for t in posted_json["query_tags"]}
+        assert tag_map["airflow_dag_id"] == "overridden"
+
 
 class TestDatabricksNotebookOperator:
     def test_is_instance_of_databricks_task_base_operator(self):
diff --git 
a/providers/databricks/tests/unit/databricks/operators/test_databricks_sql.py 
b/providers/databricks/tests/unit/databricks/operators/test_databricks_sql.py
index bfd6d89437b..1f4513b886a 100644
--- 
a/providers/databricks/tests/unit/databricks/operators/test_databricks_sql.py
+++ 
b/providers/databricks/tests/unit/databricks/operators/test_databricks_sql.py
@@ -29,7 +29,6 @@ from databricks.sql.types import Row
 from airflow.providers.common.sql.hooks.handlers import fetch_all_handler
 from airflow.providers.databricks.operators.databricks_sql import (
     DatabricksSqlOperator,
-    _get_airflow_query_tags,
 )
 
 DATE = "2017-04-20"
@@ -462,11 +461,6 @@ def test_parse_gcs_path():
 class TestDatabricksSqlOperatorQueryTags:
     """Tests for query tags support in DatabricksSqlOperator."""
 
-    def 
test_get_airflow_query_tags_returns_empty_dict_without_task_instance(self):
-        """_get_airflow_query_tags must return {} when context has no 'ti' 
key."""
-        result = _get_airflow_query_tags({})
-        assert result == {}
-
     def test_get_query_tags_with_none_context_returns_custom_tags_only(self):
         """When context is None, only custom tags are returned (no Airflow 
tags)."""
         op = DatabricksSqlOperator(
diff --git 
a/providers/databricks/tests/unit/databricks/sensors/test_databricks.py 
b/providers/databricks/tests/unit/databricks/sensors/test_databricks.py
index 8f94274e7a4..fe04781f65d 100644
--- a/providers/databricks/tests/unit/databricks/sensors/test_databricks.py
+++ b/providers/databricks/tests/unit/databricks/sensors/test_databricks.py
@@ -235,3 +235,93 @@ class TestDatabricksSQLStatementsSensor:
 
         with pytest.raises(AirflowException, match="^SQL Statement execution 
failed with terminal state: .*"):
             op.execute_complete(context=None, event=event)
+
+    def test_query_tags_defaults(self):
+        sensor = DatabricksSQLStatementsSensor(
+            task_id=TASK_ID, statement=STATEMENT, warehouse_id=WAREHOUSE_ID
+        )
+        assert sensor.query_tags == {}
+        assert sensor.include_airflow_query_tags is True
+
+    def test_query_tags_in_template_fields(self):
+        assert "query_tags" in DatabricksSQLStatementsSensor.template_fields
+
+    def test_query_tags_stored(self):
+        sensor = DatabricksSQLStatementsSensor(
+            task_id=TASK_ID, statement=STATEMENT, warehouse_id=WAREHOUSE_ID, 
query_tags={"env": "prod"}
+        )
+        assert sensor.query_tags == {"env": "prod"}
+
+    
@mock.patch("airflow.providers.databricks.sensors.databricks.DatabricksHook")
+    def test_execute_passes_query_tags_to_post_sql_statement(self, 
db_mock_class):
+        db_mock = db_mock_class.return_value
+        db_mock.post_sql_statement.return_value = STATEMENT_ID
+
+        sensor = DatabricksSQLStatementsSensor(
+            task_id=TASK_ID,
+            statement=STATEMENT,
+            warehouse_id=WAREHOUSE_ID,
+            query_tags={"team": "data"},
+            include_airflow_query_tags=False,
+        )
+        sensor.execute(context=None)
+
+        posted_json = db_mock.post_sql_statement.call_args[0][0]
+        assert posted_json["query_tags"] == [{"key": "team", "value": "data"}]
+
+    
@mock.patch("airflow.providers.databricks.sensors.databricks.DatabricksHook")
+    def test_execute_omits_query_tags_when_none(self, db_mock_class):
+        db_mock = db_mock_class.return_value
+        db_mock.post_sql_statement.return_value = STATEMENT_ID
+
+        sensor = DatabricksSQLStatementsSensor(
+            task_id=TASK_ID,
+            statement=STATEMENT,
+            warehouse_id=WAREHOUSE_ID,
+            include_airflow_query_tags=False,
+        )
+        sensor.execute(context=None)
+
+        posted_json = db_mock.post_sql_statement.call_args[0][0]
+        assert "query_tags" not in posted_json
+
+    
@mock.patch("airflow.providers.databricks.sensors.databricks.DatabricksHook")
+    def test_execute_includes_airflow_query_tags(self, db_mock_class):
+        db_mock = db_mock_class.return_value
+        db_mock.post_sql_statement.return_value = STATEMENT_ID
+
+        sensor = DatabricksSQLStatementsSensor(
+            task_id=TASK_ID,
+            statement=STATEMENT,
+            warehouse_id=WAREHOUSE_ID,
+            query_tags={"custom": "value"},
+        )
+        mock_ti = mock.MagicMock(spec=["dag_id", "task_id", "run_id", 
"try_number", "map_index", "xcom_push"])
+        mock_ti.dag_id = "my_dag"
+        mock_ti.task_id = "my_task"
+        mock_ti.run_id = "run_1"
+        mock_ti.try_number = 1
+        mock_ti.map_index = -1
+
+        sensor.execute(context={"ti": mock_ti})
+
+        posted_json = db_mock.post_sql_statement.call_args[0][0]
+        tag_keys = {t["key"] for t in posted_json["query_tags"]}
+        assert "airflow_dag_id" in tag_keys
+        assert "custom" in tag_keys
+
+    
@mock.patch("airflow.providers.databricks.sensors.databricks.DatabricksHook")
+    def test_execute_skips_query_tags_when_statement_id_provided(self, 
db_mock_class):
+        db_mock = db_mock_class.return_value
+
+        sensor = DatabricksSQLStatementsSensor(
+            task_id=TASK_ID,
+            statement_id=STATEMENT_ID,
+            warehouse_id=WAREHOUSE_ID,
+            query_tags={"env": "test"},
+            include_airflow_query_tags=False,
+            wait_for_termination=False,
+        )
+        sensor.execute(context=None)
+
+        db_mock.post_sql_statement.assert_not_called()
diff --git 
a/providers/databricks/tests/unit/databricks/sensors/test_databricks_partition.py
 
b/providers/databricks/tests/unit/databricks/sensors/test_databricks_partition.py
index 23e6d12c310..6aef232e4e1 100644
--- 
a/providers/databricks/tests/unit/databricks/sensors/test_databricks_partition.py
+++ 
b/providers/databricks/tests/unit/databricks/sensors/test_databricks_partition.py
@@ -19,6 +19,7 @@
 from __future__ import annotations
 
 from datetime import datetime, timedelta
+from unittest import mock
 from unittest.mock import patch
 
 import pytest
@@ -137,3 +138,71 @@ class TestDatabricksPartitionSensor:
             AirflowException, match=rf"Specified partition\(s\): {partitions} 
were not found."
         ):
             self.partition_sensor.poke(context={})
+
+    def _make_sensor(self, **kwargs):
+        return DatabricksPartitionSensor(
+            task_id=TASK_ID,
+            databricks_conn_id=DEFAULT_CONN_ID,
+            sql_warehouse_name=DEFAULT_SQL_WAREHOUSE,
+            table_name=DEFAULT_TABLE,
+            schema=DEFAULT_SCHEMA,
+            catalog=DEFAULT_CATALOG,
+            partitions=DEFAULT_PARTITION,
+            **kwargs,
+        )
+
+    def test_query_tags_defaults(self):
+        sensor = self._make_sensor()
+        assert sensor.query_tags == {}
+        assert sensor.include_airflow_query_tags is True
+
+    def test_query_tags_in_template_fields(self):
+        assert "query_tags" in DatabricksPartitionSensor.template_fields
+
+    def test_query_tags_stored(self):
+        sensor = self._make_sensor(query_tags={"team": "data"})
+        assert sensor.query_tags == {"team": "data"}
+
+    def test_poke_sets_query_tags_on_hook(self):
+        sensor = self._make_sensor(query_tags={"env": "test"}, 
include_airflow_query_tags=False)
+        with patch.object(sensor, "_check_table_partitions", 
return_value=[True]):
+            sensor.poke(context=None)
+        assert sensor._get_hook.query_tags == {"env": "test"}
+
+    def test_poke_no_tags_when_disabled_and_no_custom(self):
+        sensor = self._make_sensor(include_airflow_query_tags=False)
+        with patch.object(sensor, "_check_table_partitions", 
return_value=[True]):
+            sensor.poke(context=None)
+        assert sensor._get_hook.query_tags is None
+
+    def test_poke_includes_airflow_tags_from_context(self):
+        sensor = self._make_sensor(query_tags={"custom": "value"})
+        mock_ti = mock.MagicMock(spec=["dag_id", "task_id", "run_id", 
"try_number", "map_index"])
+        mock_ti.dag_id = "my_dag"
+        mock_ti.task_id = "my_task"
+        mock_ti.run_id = "run_1"
+        mock_ti.try_number = 1
+        mock_ti.map_index = -1
+        mock_context = {"ti": mock_ti}
+
+        with patch.object(sensor, "_check_table_partitions", 
return_value=[True]):
+            sensor.poke(context=mock_context)
+
+        tags = sensor._get_hook.query_tags
+        assert tags is not None
+        assert tags["airflow_dag_id"] == "my_dag"
+        assert tags["custom"] == "value"
+
+    def test_custom_tags_override_airflow_tags(self):
+        sensor = self._make_sensor(query_tags={"airflow_dag_id": "overridden"})
+        mock_ti = mock.MagicMock(spec=["dag_id", "task_id", "run_id", 
"try_number", "map_index"])
+        mock_ti.dag_id = "original"
+        mock_ti.task_id = "t"
+        mock_ti.run_id = "r"
+        mock_ti.try_number = 1
+        mock_ti.map_index = -1
+
+        with patch.object(sensor, "_check_table_partitions", 
return_value=[True]):
+            sensor.poke(context={"ti": mock_ti})
+
+        assert sensor._get_hook.query_tags["airflow_dag_id"] == "overridden"
diff --git 
a/providers/databricks/tests/unit/databricks/sensors/test_databricks_sql.py 
b/providers/databricks/tests/unit/databricks/sensors/test_databricks_sql.py
index 3431722f770..00f3ff4749a 100644
--- a/providers/databricks/tests/unit/databricks/sensors/test_databricks_sql.py
+++ b/providers/databricks/tests/unit/databricks/sensors/test_databricks_sql.py
@@ -19,6 +19,7 @@
 from __future__ import annotations
 
 from datetime import datetime, timedelta
+from unittest import mock
 from unittest.mock import patch
 
 import pytest
@@ -100,3 +101,71 @@ class TestDatabricksSqlSensor:
             " Please specify either http_path or sql_warehouse_name.",
         ):
             self.sensor._get_results()
+
+    def _make_sensor(self, **kwargs):
+        return DatabricksSqlSensor(
+            task_id=TASK_ID,
+            databricks_conn_id=DEFAULT_CONN_ID,
+            sql_warehouse_name=DEFAULT_SQL_WAREHOUSE,
+            sql=DEFAULT_SQL,
+            **kwargs,
+        )
+
+    def test_query_tags_defaults(self):
+        sensor = self._make_sensor()
+        assert sensor.query_tags == {}
+        assert sensor.include_airflow_query_tags is True
+
+    def test_query_tags_in_template_fields(self):
+        assert "query_tags" in DatabricksSqlSensor.template_fields
+
+    def test_query_tags_stored(self):
+        sensor = self._make_sensor(query_tags={"env": "prod"})
+        assert sensor.query_tags == {"env": "prod"}
+
+    def test_poke_sets_query_tags_on_hook(self):
+        sensor = self._make_sensor(query_tags={"env": "test"}, 
include_airflow_query_tags=False)
+        with patch.object(sensor, "_get_results", return_value=True) as 
mock_results:
+            sensor.poke(context=None)
+        assert sensor.hook.query_tags == {"env": "test"}
+        mock_results.assert_called_once()
+
+    def test_poke_no_tags_when_disabled_and_no_custom(self):
+        sensor = self._make_sensor(include_airflow_query_tags=False)
+        with patch.object(sensor, "_get_results", return_value=True):
+            sensor.poke(context=None)
+        assert sensor.hook.query_tags is None
+
+    def test_poke_includes_airflow_tags_from_context(self):
+        sensor = self._make_sensor(query_tags={"custom": "value"})
+        mock_ti = mock.MagicMock(spec=["dag_id", "task_id", "run_id", 
"try_number", "map_index"])
+        mock_ti.dag_id = "my_dag"
+        mock_ti.task_id = "my_task"
+        mock_ti.run_id = "run_1"
+        mock_ti.try_number = 1
+        mock_ti.map_index = -1
+        mock_context = {"ti": mock_ti}
+
+        with patch.object(sensor, "_get_results", return_value=True):
+            sensor.poke(context=mock_context)
+
+        tags = sensor.hook.query_tags
+        assert tags is not None
+        assert tags["airflow_dag_id"] == "my_dag"
+        assert tags["airflow_task_id"] == "my_task"
+        assert tags["custom"] == "value"
+
+    def test_custom_tags_override_airflow_tags(self):
+        sensor = self._make_sensor(query_tags={"airflow_dag_id": "overridden"})
+        mock_ti = mock.MagicMock(spec=["dag_id", "task_id", "run_id", 
"try_number", "map_index"])
+        mock_ti.dag_id = "original"
+        mock_ti.task_id = "t"
+        mock_ti.run_id = "r"
+        mock_ti.try_number = 1
+        mock_ti.map_index = -1
+        mock_context = {"ti": mock_ti}
+
+        with patch.object(sensor, "_get_results", return_value=True):
+            sensor.poke(context=mock_context)
+
+        assert sensor.hook.query_tags["airflow_dag_id"] == "overridden"
diff --git 
a/providers/databricks/tests/unit/databricks/utils/test_query_tags.py 
b/providers/databricks/tests/unit/databricks/utils/test_query_tags.py
new file mode 100644
index 00000000000..4c2ee00a77f
--- /dev/null
+++ b/providers/databricks/tests/unit/databricks/utils/test_query_tags.py
@@ -0,0 +1,106 @@
+# Licensed to the Apache Software Foundation (ASF) under one
+# or more contributor license agreements.  See the NOTICE file
+# distributed with this work for additional information
+# regarding copyright ownership.  The ASF licenses this file
+# to you under the Apache License, Version 2.0 (the
+# "License"); you may not use this file except in compliance
+# with the License.  You may obtain a copy of the License at
+#
+#   http://www.apache.org/licenses/LICENSE-2.0
+#
+# Unless required by applicable law or agreed to in writing,
+# software distributed under the License is distributed on an
+# "AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY
+# KIND, either express or implied.  See the License for the
+# specific language governing permissions and limitations
+# under the License.
+from __future__ import annotations
+
+from unittest import mock
+
+from airflow.providers.databricks.utils.query_tags import (
+    build_query_tags,
+    dict_to_query_tag_list,
+    get_airflow_query_tags,
+)
+
+
+def _make_context():
+    ti = mock.MagicMock(spec=["dag_id", "task_id", "run_id", "try_number", 
"map_index"])
+    ti.dag_id = "test_dag"
+    ti.task_id = "test_task"
+    ti.run_id = "test_run"
+    ti.try_number = 1
+    ti.map_index = -1
+    return {"ti": ti}
+
+
+class TestGetAirflowQueryTags:
+    def test_returns_empty_dict_without_task_instance(self):
+        assert get_airflow_query_tags({}) == {}
+
+    def test_returns_stringified_context_metadata(self):
+        result = get_airflow_query_tags(_make_context())
+        assert result == {
+            "airflow_dag_id": "test_dag",
+            "airflow_task_id": "test_task",
+            "airflow_run_id": "test_run",
+            "airflow_try_number": "1",
+            "airflow_map_index": "-1",
+        }
+
+    def test_none_valued_attributes_become_none(self):
+        ti = mock.MagicMock(spec=["dag_id", "task_id", "run_id", "try_number", 
"map_index"])
+        ti.dag_id = "test_dag"
+        ti.task_id = None
+        ti.run_id = None
+        ti.try_number = None
+        ti.map_index = None
+        result = get_airflow_query_tags({"ti": ti})
+        assert result["airflow_dag_id"] == "test_dag"
+        assert result["airflow_task_id"] is None
+        assert result["airflow_run_id"] is None
+
+
+class TestBuildQueryTags:
+    def test_none_context_returns_user_tags_only(self):
+        assert build_query_tags(None, {"custom": "value"}, 
include_airflow_query_tags=True) == {
+            "custom": "value"
+        }
+
+    def test_none_context_and_no_user_tags_returns_none(self):
+        assert build_query_tags(None, {}, include_airflow_query_tags=True) is 
None
+
+    def test_disabled_airflow_tags_returns_user_tags_only(self):
+        result = build_query_tags(_make_context(), {"custom": "value"}, 
include_airflow_query_tags=False)
+        assert result == {"custom": "value"}
+
+    def test_disabled_airflow_tags_and_no_user_tags_returns_none(self):
+        assert build_query_tags(_make_context(), {}, 
include_airflow_query_tags=False) is None
+
+    def test_merges_airflow_and_user_tags(self):
+        result = build_query_tags(_make_context(), {"custom": "value"}, 
include_airflow_query_tags=True)
+        assert result is not None
+        assert result["airflow_dag_id"] == "test_dag"
+        assert result["custom"] == "value"
+
+    def test_user_tags_override_airflow_tags_on_collision(self):
+        result = build_query_tags(
+            _make_context(), {"airflow_dag_id": "overridden"}, 
include_airflow_query_tags=True
+        )
+        assert result is not None
+        assert result["airflow_dag_id"] == "overridden"
+
+
+class TestDictToQueryTagList:
+    def test_converts_dict_to_key_value_list(self):
+        assert dict_to_query_tag_list({"a": "1", "b": "2"}) == [
+            {"key": "a", "value": "1"},
+            {"key": "b", "value": "2"},
+        ]
+
+    def test_omits_none_values(self):
+        assert dict_to_query_tag_list({"a": "1", "b": None}) == [{"key": "a", 
"value": "1"}]
+
+    def test_empty_dict_returns_empty_list(self):
+        assert dict_to_query_tag_list({}) == []


Reply via email to